Compare commits
47 Commits
6f62b37c3a
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7ea8ca4d | ||
|
|
c00fe6b06c | ||
|
|
dcaa1f8c3c | ||
|
|
c49493bb44 | ||
|
|
c9cd2d734d | ||
|
|
ef72f5fc58 | ||
|
|
8b440dd60b | ||
|
|
9a55dc7f81 | ||
|
|
3ac7c7a5b3 | ||
|
|
96d7ae5747 | ||
|
|
f7b7eaeaff | ||
|
|
32e621df14 | ||
|
|
6c5297836c | ||
|
|
9c7e564f6f | ||
|
|
4046a3c5b3 | ||
|
|
3e83dcfa15 | ||
|
|
b0ec4fd4b7 | ||
|
|
6ee52ffc8e | ||
|
|
450fe1b3eb | ||
|
|
f1d42818ee | ||
|
|
e0e0551a83 | ||
|
|
97c600df14 | ||
|
|
6c47cdbd83 | ||
|
|
21513b20c4 | ||
|
|
bd6007f299 | ||
|
|
b162fc8a4f | ||
|
|
a5449d2adb | ||
|
|
a5048634b8 | ||
|
|
b5d64b3f0a | ||
|
|
d21669ee6d | ||
|
|
3fd7329dc5 | ||
|
|
c449e9e0a8 | ||
|
|
689cfa18cf | ||
|
|
6fd4756f35 | ||
|
|
a5dba298f3 | ||
|
|
4029cd660d | ||
|
|
b754af20e6 | ||
|
|
3f31d6f5bb | ||
|
|
8eff9106f5 | ||
|
|
af30449071 | ||
|
|
98c3ebb96c | ||
|
|
9e2040cefc | ||
|
|
719071345e | ||
|
|
efafd38b1a | ||
|
|
5c70b26508 | ||
|
|
ede591c89e | ||
|
|
2defd7a4a9 |
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Gitea CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, production]
|
||||
pull_request:
|
||||
branches: [main, dev, production]
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -68,7 +68,12 @@ 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..."
|
||||
docker network create portfolio_dev 2>/dev/null || true
|
||||
docker network create proxy 2>/dev/null || true
|
||||
|
||||
# Pull images with correct architecture (Docker will auto-detect)
|
||||
echo "📥 Pulling images for current architecture..."
|
||||
@@ -192,25 +197,6 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure networks exist
|
||||
echo "🌐 Checking for networks..."
|
||||
if ! docker network inspect proxy >/dev/null 2>&1; then
|
||||
echo "⚠️ Proxy network not found, creating it..."
|
||||
docker network create proxy 2>/dev/null || echo "Network might already exist or creation failed"
|
||||
else
|
||||
echo "✅ Proxy network exists"
|
||||
fi
|
||||
|
||||
if ! docker network inspect portfolio_dev >/dev/null 2>&1; then
|
||||
echo "⚠️ Portfolio dev network not found, creating it..."
|
||||
docker network create portfolio_dev 2>/dev/null || echo "Network might already exist or creation failed"
|
||||
else
|
||||
echo "✅ Portfolio dev network exists"
|
||||
fi
|
||||
|
||||
# Connect proxy network to portfolio_dev network if needed
|
||||
# (This allows the app to access both proxy and DB/Redis)
|
||||
|
||||
# Start new container with updated image
|
||||
echo "🆕 Starting new dev container..."
|
||||
docker run -d \
|
||||
@@ -73,6 +73,9 @@ jobs:
|
||||
export DIRECTUS_URL="${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}"
|
||||
export DIRECTUS_STATIC_TOKEN="${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}"
|
||||
|
||||
# Ensure the shared network exists before compose tries to use it
|
||||
docker network create portfolio_net 2>/dev/null || true
|
||||
|
||||
# Start new container with updated image (docker-compose will handle this)
|
||||
echo "🆕 Starting new production container..."
|
||||
echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}"
|
||||
|
||||
211
.github/copilot-instructions.md
vendored
Normal file
211
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
# Portfolio Project Instructions
|
||||
|
||||
This is Dennis Konkol's personal portfolio (dk0.dev) - a Next.js 15 portfolio with Directus CMS integration, n8n automation, and a "liquid" design system.
|
||||
|
||||
## Build, Test, and Lint
|
||||
|
||||
### Development
|
||||
```bash
|
||||
npm run dev # Full dev environment (Docker + Next.js)
|
||||
npm run dev:simple # Next.js only (no Docker dependencies)
|
||||
npm run dev:next # Plain Next.js dev server
|
||||
```
|
||||
|
||||
### Build & Deploy
|
||||
```bash
|
||||
npm run build # Production build (standalone mode)
|
||||
npm run start # Start production server
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Unit tests (Jest)
|
||||
npm run test # Run all unit tests
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:coverage # With coverage report
|
||||
|
||||
# E2E tests (Playwright)
|
||||
npm run test:e2e # Run all E2E tests
|
||||
npm run test:e2e:ui # Interactive UI mode
|
||||
npm run test:critical # Critical paths only
|
||||
npm run test:hydration # Hydration tests only
|
||||
```
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
npm run lint # Run ESLint
|
||||
npm run lint:fix # Auto-fix issues
|
||||
```
|
||||
|
||||
### Database (Prisma)
|
||||
```bash
|
||||
npm run db:generate # Generate Prisma client
|
||||
npm run db:push # Push schema to database
|
||||
npm run db:studio # Open Prisma Studio
|
||||
npm run db:seed # Seed database
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Tech Stack
|
||||
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
|
||||
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
||||
- **Theming**: next-themes for dark mode (system/light/dark)
|
||||
- **Animations**: Framer Motion 12
|
||||
- **3D**: Three.js + React Three Fiber (shader gradient background)
|
||||
- **Database**: PostgreSQL via Prisma ORM
|
||||
- **Cache**: Redis (optional)
|
||||
- **CMS**: Directus (self-hosted, GraphQL, optional)
|
||||
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
||||
- **i18n**: next-intl (EN + DE)
|
||||
- **Monitoring**: Sentry
|
||||
- **Deployment**: Docker (standalone mode) + Nginx
|
||||
|
||||
### Key Directories
|
||||
```
|
||||
app/
|
||||
[locale]/ # i18n routes (en, de)
|
||||
page.tsx # Homepage sections
|
||||
projects/ # Project listing + detail pages
|
||||
api/ # API routes
|
||||
book-reviews/ # Book reviews from Directus
|
||||
hobbies/ # Hobbies from Directus
|
||||
n8n/ # n8n webhook proxies
|
||||
projects/ # Projects (PostgreSQL + Directus)
|
||||
tech-stack/ # Tech stack from Directus
|
||||
components/ # React components
|
||||
lib/
|
||||
directus.ts # Directus GraphQL client (no SDK)
|
||||
auth.ts # Auth + rate limiting
|
||||
translations-loader.ts # i18n loaders for server components
|
||||
prisma/
|
||||
schema.prisma # Database schema
|
||||
messages/
|
||||
en.json # English translations
|
||||
de.json # German translations
|
||||
```
|
||||
|
||||
### Data Source Fallback Chain
|
||||
The architecture prioritizes resilience with this fallback hierarchy:
|
||||
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured)
|
||||
2. **PostgreSQL** (for projects, analytics)
|
||||
3. **JSON files** (`messages/*.json`)
|
||||
4. **Hardcoded defaults**
|
||||
5. **Display key itself** (last resort)
|
||||
|
||||
**Critical**: The site never crashes if external services (Directus, PostgreSQL, n8n, Redis) are unavailable. All API routes return graceful fallbacks.
|
||||
|
||||
### CMS Integration (Directus)
|
||||
- GraphQL calls via `lib/directus.ts` (no Directus SDK)
|
||||
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
||||
- Translations use Directus native system (M2O to `languages`)
|
||||
- Locale mapping: `en` → `en-US`, `de` → `de-DE`
|
||||
- API routes export `runtime='nodejs'`, `dynamic='force-dynamic'` and include a `source` field in JSON responses (`directus|fallback|error`)
|
||||
|
||||
### n8n Integration
|
||||
- Webhook base URL: `N8N_WEBHOOK_URL` env var
|
||||
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||
- All endpoints have rate limiting and 10s timeout protection
|
||||
- Hardcover reading data cached for 5 minutes
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### i18n (Internationalization)
|
||||
- **Supported locales**: `en` (English), `de` (German)
|
||||
- **Primary source**: Static JSON files in `messages/en.json` and `messages/de.json`
|
||||
- **Optional override**: Directus CMS `messages` collection
|
||||
- **Server components**: Use `getHeroTranslations()`, `getNavTranslations()`, etc. from `lib/translations-loader.ts`
|
||||
- **Client components**: Use `useTranslations("key.path")` from next-intl
|
||||
- **Locale mapping**: Middleware defines `["en", "de"]` which must match `app/[locale]/layout.tsx`
|
||||
|
||||
### Component Patterns
|
||||
- **Client components**: Mark with `"use client"` for interactive/data-fetching parts
|
||||
- **Data loading**: Use `useEffect` for client-side fetching on mount
|
||||
- **Animations**: Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
|
||||
- **Loading states**: Every async component needs a matching Skeleton component
|
||||
|
||||
### Design System ("Liquid Editorial Bento")
|
||||
- **Core palette**: Cream (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`)
|
||||
- **Custom colors**: Prefixed with `liquid-*` (sky, mint, lavender, pink, rose, peach, coral, teal, lime)
|
||||
- **Card style**: Gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`)
|
||||
- **Glassmorphism**: Use `backdrop-blur-sm` with `border-2` and `rounded-xl`
|
||||
- **Typography**: Headlines uppercase, tracking-tighter, with accent point at end
|
||||
- **Layout**: Bento Grid for new features (no floating overlays)
|
||||
|
||||
### File Naming
|
||||
- **Components**: PascalCase in `app/components/` (e.g., `About.tsx`)
|
||||
- **API routes**: kebab-case directories in `app/api/` (e.g., `book-reviews/`)
|
||||
- **Lib utilities**: kebab-case in `lib/` (e.g., `email-obfuscate.ts`)
|
||||
|
||||
### Code Style
|
||||
- **Language**: Code in English, user-facing text via i18n
|
||||
- **TypeScript**: No `any` types - use interfaces from `lib/directus.ts` or `app/_ui/`
|
||||
- **Error handling**: All API calls must catch errors with fallbacks
|
||||
- **Error logging**: Only in development mode (`process.env.NODE_ENV === "development"`)
|
||||
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||
- **No emojis**: Unless explicitly requested
|
||||
|
||||
### Testing Notes
|
||||
- **Jest environment**: JSDOM with mocks for `window.matchMedia` and `IntersectionObserver`
|
||||
- **Playwright**: Uses plain Next.js dev server (no Docker) with `NODE_ENV=development` to avoid Edge runtime issues
|
||||
- **Transform**: ESM modules (react-markdown, remark-*, etc.) are transformed via `transformIgnorePatterns`
|
||||
- **After UI changes**: Run `npm run test` to verify no regressions
|
||||
|
||||
### Docker & Deployment
|
||||
- **Standalone mode**: `next.config.ts` uses `output: "standalone"` for optimized Docker builds
|
||||
- **Branches**: `dev` → staging, `production` → live
|
||||
- **CI/CD**: Gitea Actions (`.gitea/workflows/`)
|
||||
- **Verify Docker builds**: Always test Docker builds after changes to `next.config.ts` or dependencies
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a CMS-managed section
|
||||
1. Define GraphQL query + types in `lib/directus.ts`
|
||||
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
|
||||
3. Create component in `app/components/<Name>.tsx`
|
||||
4. Add i18n keys to `messages/en.json` and `messages/de.json`
|
||||
5. Integrate into parent component
|
||||
|
||||
### Adding i18n strings
|
||||
1. Add keys to both `messages/en.json` and `messages/de.json`
|
||||
2. Use `useTranslations("key.path")` in client components
|
||||
3. Use `getTranslations("key.path")` in server components
|
||||
|
||||
### Working with Directus
|
||||
- All queries go through `directusRequest()` in `lib/directus.ts`
|
||||
- Uses GraphQL endpoint (`/graphql`) with 2s timeout
|
||||
- Returns `null` on failure (graceful degradation)
|
||||
- Translations filtered by `languages_code.code` matching Directus locale
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required for CMS
|
||||
```bash
|
||||
DIRECTUS_URL=https://cms.dk0.dev
|
||||
DIRECTUS_STATIC_TOKEN=...
|
||||
```
|
||||
|
||||
### Required for n8n features
|
||||
```bash
|
||||
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
||||
N8N_SECRET_TOKEN=...
|
||||
N8N_API_KEY=...
|
||||
```
|
||||
|
||||
### Database & Cache
|
||||
```bash
|
||||
DATABASE_URL=postgresql://...
|
||||
REDIS_URL=redis://...
|
||||
```
|
||||
|
||||
### Optional
|
||||
```bash
|
||||
SENTRY_DSN=...
|
||||
NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
||||
```
|
||||
|
||||
## Documentation References
|
||||
- Operations guide: `docs/OPERATIONS.md`
|
||||
- Locale system: `docs/LOCALE_SYSTEM.md`
|
||||
- CMS guide: `docs/CMS_GUIDE.md`
|
||||
- Testing & deployment: `docs/TESTING_AND_DEPLOYMENT.md`
|
||||
334
.github/workflows/ci-cd.yml
vendored
334
.github/workflows/ci-cd.yml
vendored
@@ -1,334 +0,0 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, production]
|
||||
pull_request:
|
||||
branches: [main, dev, production]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Test Job (parallel)
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: self-hosted # Use your own server for speed!
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.npm
|
||||
node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Create test environment file
|
||||
run: |
|
||||
cat > .env <<EOF
|
||||
NODE_ENV=test
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
MY_EMAIL=test@example.com
|
||||
MY_INFO_EMAIL=test@example.com
|
||||
MY_PASSWORD=test
|
||||
MY_INFO_PASSWORD=test
|
||||
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
|
||||
ADMIN_BASIC_AUTH=admin:test
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
# Security scan (parallel)
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: self-hosted # Use your own server for speed!
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
format: 'sarif'
|
||||
output: 'trivy-results.sarif'
|
||||
skip-version-check: true
|
||||
scanners: 'vuln,secret,config'
|
||||
|
||||
- name: Upload Trivy scan results as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: trivy-security-report
|
||||
path: trivy-results.sarif
|
||||
retention-days: 30
|
||||
|
||||
# Build and push Docker image
|
||||
build:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: self-hosted # Use your own server for speed!
|
||||
needs: [test, security] # Wait for parallel jobs to complete
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/production')
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha,prefix={{branch}}-
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=staging,enable={{is_default_branch==false && branch=='dev'}}
|
||||
type=raw,value=staging,enable={{is_default_branch==false && branch=='main'}}
|
||||
|
||||
- name: Create production environment file
|
||||
run: |
|
||||
cat > .env <<EOF
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
|
||||
MY_EMAIL=${{ vars.MY_EMAIL }}
|
||||
MY_INFO_EMAIL=${{ vars.MY_INFO_EMAIL }}
|
||||
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
|
||||
MY_INFO_PASSWORD=${{ secrets.MY_INFO_PASSWORD }}
|
||||
NEXT_PUBLIC_UMAMI_URL=${{ vars.NEXT_PUBLIC_UMAMI_URL }}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
ADMIN_BASIC_AUTH=${{ secrets.ADMIN_BASIC_AUTH }}
|
||||
LOG_LEVEL=info
|
||||
EOF
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64 # Only AMD64 for speed
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# Optimize for speed
|
||||
build-args: |
|
||||
BUILDKIT_INLINE_CACHE=1
|
||||
|
||||
# Deploy to staging (dev/main branches)
|
||||
deploy-staging:
|
||||
name: Deploy to Staging
|
||||
runs-on: self-hosted
|
||||
needs: build
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
|
||||
environment: staging
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy staging to server
|
||||
run: |
|
||||
# Set deployment variables
|
||||
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging"
|
||||
export CONTAINER_NAME="portfolio-app-staging"
|
||||
export COMPOSE_FILE="docker-compose.staging.yml"
|
||||
|
||||
# Set environment variables for docker-compose
|
||||
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL_STAGING || vars.NEXT_PUBLIC_BASE_URL }}"
|
||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
||||
|
||||
# Pull latest staging image
|
||||
docker pull $IMAGE_NAME || docker pull "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main" || true
|
||||
|
||||
# Stop and remove old staging container (if exists)
|
||||
docker compose -f $COMPOSE_FILE down || true
|
||||
|
||||
# Start new staging container
|
||||
docker compose -f $COMPOSE_FILE up -d --force-recreate
|
||||
|
||||
# Wait for health check
|
||||
echo "Waiting for staging application to be healthy..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
|
||||
echo "✅ Staging deployment successful!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify deployment
|
||||
if curl -f http://localhost:3002/api/health; then
|
||||
echo "✅ Staging deployment verified!"
|
||||
else
|
||||
echo "⚠️ Staging health check failed, but container is running"
|
||||
docker compose -f $COMPOSE_FILE logs --tail=50
|
||||
fi
|
||||
|
||||
# Deploy to production
|
||||
deploy:
|
||||
name: Deploy to Production
|
||||
runs-on: self-hosted
|
||||
needs: build
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to production (zero-downtime)
|
||||
run: |
|
||||
# Set deployment variables
|
||||
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production"
|
||||
export CONTAINER_NAME="portfolio-app"
|
||||
export COMPOSE_FILE="docker-compose.production.yml"
|
||||
export BACKUP_CONTAINER="portfolio-app-backup"
|
||||
|
||||
# Set environment variables for docker-compose
|
||||
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
|
||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
||||
|
||||
# Pull latest production image
|
||||
echo "📦 Pulling latest production image..."
|
||||
docker pull $IMAGE_NAME
|
||||
|
||||
# Check if production container is running
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo "🔄 Production container is running - performing zero-downtime deployment..."
|
||||
|
||||
# Start new container with different name first (blue-green)
|
||||
echo "🚀 Starting new container (green)..."
|
||||
docker run -d \
|
||||
--name ${BACKUP_CONTAINER} \
|
||||
--network portfolio_net \
|
||||
-p 3002:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
|
||||
-e REDIS_URL=redis://redis:6379 \
|
||||
-e NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}" \
|
||||
-e MY_EMAIL="${{ vars.MY_EMAIL }}" \
|
||||
-e MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}" \
|
||||
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
|
||||
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
|
||||
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
|
||||
$IMAGE_NAME || true
|
||||
|
||||
# Wait for new container to be healthy
|
||||
echo "⏳ Waiting for new container to be healthy..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
|
||||
echo "✅ New container is healthy!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Stop old container
|
||||
echo "🛑 Stopping old container..."
|
||||
docker stop ${CONTAINER_NAME} || true
|
||||
|
||||
# Remove old container
|
||||
docker rm ${CONTAINER_NAME} || true
|
||||
|
||||
# Rename new container to production name
|
||||
docker rename ${BACKUP_CONTAINER} ${CONTAINER_NAME}
|
||||
|
||||
# Update port mapping (requires container restart, but it's already healthy)
|
||||
docker stop ${CONTAINER_NAME}
|
||||
docker rm ${CONTAINER_NAME}
|
||||
|
||||
# Start with correct port using docker-compose
|
||||
docker compose -f $COMPOSE_FILE up -d --force-recreate
|
||||
else
|
||||
echo "🆕 No existing container - starting fresh deployment..."
|
||||
docker compose -f $COMPOSE_FILE up -d --force-recreate
|
||||
fi
|
||||
|
||||
# Wait for health check
|
||||
echo "⏳ Waiting for production application to be healthy..."
|
||||
for i in {1..30}; do
|
||||
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
|
||||
echo "✅ Production deployment successful!"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Verify deployment
|
||||
if curl -f http://localhost:3000/api/health; then
|
||||
echo "✅ Production deployment verified!"
|
||||
else
|
||||
echo "❌ Production deployment failed!"
|
||||
docker compose -f $COMPOSE_FILE logs --tail=100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cleanup backup container if it exists
|
||||
docker rm -f ${BACKUP_CONTAINER} 2>/dev/null || true
|
||||
|
||||
- name: Cleanup old images
|
||||
run: |
|
||||
# Remove unused images older than 7 days
|
||||
docker image prune -f --filter "until=168h"
|
||||
|
||||
# Remove unused containers
|
||||
docker container prune -f
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,5 +1,9 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Local tooling
|
||||
.claude/
|
||||
._*
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
34
GEMINI.md
Normal file
34
GEMINI.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Gemini CLI: Project Context & Engineering Mandates
|
||||
|
||||
## Project Identity
|
||||
- **Name:** Dennis Konkol Portfolio (dk0.dev)
|
||||
- **Aesthetic:** "Liquid Editorial Bento" (Premium, minimalistisch, hoch-typografisch).
|
||||
- **Core Palette:** Creme (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`), Sky, Purple.
|
||||
|
||||
## Tech Stack
|
||||
- **Framework:** Next.js 15 (App Router), Tailwind CSS 3.4.
|
||||
- **CMS:** Directus (primär für Texte, Hobbies, Tech-Stack, Projekte).
|
||||
- **Database:** PostgreSQL (Prisma) als lokaler Cache/Mirror für Projekte.
|
||||
- **Animations:** Framer Motion (bevorzugt für alle Übergänge).
|
||||
- **i18n:** `next-intl` (Locales: `en`, `de`).
|
||||
|
||||
## Engineering Guidelines (Mandates)
|
||||
|
||||
### 1. UI Components
|
||||
- **Bento Grid:** Neue Features sollten immer in das bestehende Grid integriert werden. Keine schwebenden Overlays.
|
||||
- **Skeletons:** Jede asynchrone Komponente benötigt einen passenden `Skeleton` Ladezustand.
|
||||
- **Typography:** Headlines immer uppercase, tracking-tighter, mit Akzent-Punkt am Ende.
|
||||
|
||||
### 2. Implementation Rules
|
||||
- **TypeScript:** Keine `any`. Nutze bestehende Interfaces in `lib/directus.ts` oder `app/_ui/`.
|
||||
- **Resilience:** Alle API-Calls müssen Fehler abfangen und sinnvolle Fallbacks (oder Skeletons) anzeigen.
|
||||
- **Next.js Standalone:** Das Projekt nutzt den `standalone` Build-Mode. Docker-Builds müssen immer verifiziert werden.
|
||||
|
||||
### 3. Agent Instructions
|
||||
- **Codebase Investigator:** Nutze dieses Tool für Architektur-Fragen.
|
||||
- **Testing:** Führe `npm run test` nach UI-Änderungen aus. Achte auf JSDOM-Einschränkungen (Mocking von `window.matchMedia` und `IntersectionObserver`).
|
||||
- **CMS First:** Texte sollten nach Möglichkeit aus der `messages` Collection in Directus kommen, nicht hartcodiert werden.
|
||||
|
||||
## Current State
|
||||
- **Branch:** `dev` (pushed)
|
||||
- **Status:** Design Overhaul abgeschlossen, Build stabil, Docker verifiziert.
|
||||
42
SESSION_SUMMARY.md
Normal file
42
SESSION_SUMMARY.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Session Summary - February 16, 2026
|
||||
|
||||
## 🛡️ Security & Technical Fixes
|
||||
- **CSP Improvements:** Added `images.unsplash.com`, `*.dk0.dev`, and `localhost` to `img-src` and `connect-src`.
|
||||
- **Worker Support:** Enabled `worker-src 'self' blob:;` for dynamic features.
|
||||
- **Source Map Suppression:** Configured Webpack to ignore 404 errors for `framer-motion` and `LayoutGroupContext` source maps in development.
|
||||
- **Project Filtering:** Unified the projects API to use Directus as the "Single Source of Truth," strictly enforcing the `published` status.
|
||||
|
||||
## 🎨 UI/UX Enhancements (Liquid Editorial Bento)
|
||||
- **Hero Section:**
|
||||
- Stabilized the hero photo (removed floating animation).
|
||||
- Fixed edge-clipping by increasing the border/padding.
|
||||
- Removed redundant social buttons for a cleaner entry.
|
||||
- **Activity Feed:**
|
||||
- Full localization (DE/EN).
|
||||
- Added a rotating cycle of CS-related quotes (Dijkstra, etc.) including CMS quotes.
|
||||
- Redesigned Music UI with Spotify-themed branding (`#1DB954`), improved contrast, and animated frequency bars.
|
||||
- **Contact Area:**
|
||||
- Redesigned into a unified "Connect" Bento box.
|
||||
- High-typography list style for Email, GitHub, LinkedIn, and Location.
|
||||
- **Hobbies:**
|
||||
- Added personalized descriptions reflecting interests like Analog Photography, Astronomy, and Traveling.
|
||||
- Switched to a 4-column layout for better spatial balance.
|
||||
|
||||
## 🚀 New Features
|
||||
- **Snippets System ("The Lab"):**
|
||||
- New Directus collection and API endpoint for technical notes.
|
||||
- Interactive Bento-modals with code syntax highlighting and copy-to-clipboard functionality.
|
||||
- Dedicated `/snippets` overview page.
|
||||
- Implemented "Featured" logic to control visibility on the home page.
|
||||
- **Redesigned 404 Page:**
|
||||
- Completely rebuilt in the Editorial Bento style with clear navigation paths.
|
||||
- **Visual Finish:**
|
||||
- Added a subtle, animated CSS-based Grain/Noise overlay.
|
||||
- Implemented smooth Page Transitions using Framer Motion.
|
||||
|
||||
## 💻 Hardware Setup ("My Gear")
|
||||
- Added a dedicated Bento card showing current dev setup:
|
||||
- MacBook Pro M4 Pro (24GB RAM).
|
||||
- PC: Ryzen 7 3800XT / RTX 3080.
|
||||
- Server: IONOS Cloud & Raspberry Pi 4.
|
||||
- Dual MSI 164Hz Curved Monitors.
|
||||
@@ -3,7 +3,7 @@ import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
import { getProjectBySlug, Project } from "@/lib/directus";
|
||||
import { getProjectBySlug } from "@/lib/directus";
|
||||
import { ProjectDetailData } from "@/app/_ui/ProjectDetailClient";
|
||||
|
||||
export const revalidate = 300;
|
||||
@@ -83,7 +83,7 @@ export default async function ProjectPage({
|
||||
if (directusProject) {
|
||||
projectData = {
|
||||
...directusProject,
|
||||
id: parseInt(directusProject.id) || 0,
|
||||
id: typeof directusProject.id === 'string' ? (parseInt(directusProject.id) || 0) : directusProject.id,
|
||||
} as ProjectDetailData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,29 +46,34 @@ export default async function ProjectsPage({
|
||||
if (fetched) {
|
||||
directusProjects = fetched.map(p => ({
|
||||
...p,
|
||||
id: parseInt(p.id) || 0,
|
||||
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||
})) as ProjectListItem[];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Directus projects fetch failed:", err);
|
||||
}
|
||||
|
||||
const localizedDb = dbProjects.map((p) => {
|
||||
const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
|
||||
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||
const trDefault = p.translations?.find(
|
||||
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
||||
);
|
||||
const tr = trPreferred ?? trDefault;
|
||||
const { translations: _translations, ...rest } = p;
|
||||
return {
|
||||
...rest,
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
title: tr?.title ?? p.title,
|
||||
description: tr?.description ?? p.description,
|
||||
tags: p.tags,
|
||||
category: p.category,
|
||||
date: p.date,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
imageUrl: p.imageUrl,
|
||||
};
|
||||
});
|
||||
|
||||
// Merge projects, prioritizing DB ones if slugs match
|
||||
const allProjects: any[] = [...localizedDb];
|
||||
const allProjects: ProjectListItem[] = [...localizedDb];
|
||||
const dbSlugs = new Set(localizedDb.map(p => p.slug));
|
||||
|
||||
for (const dp of directusProjects) {
|
||||
|
||||
109
app/[locale]/snippets/SnippetsClient.tsx
Normal file
109
app/[locale]/snippets/SnippetsClient.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Snippet } from "@/lib/directus";
|
||||
import { X, Copy, Check, Hash } from "lucide-react";
|
||||
|
||||
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||
{initialSnippets.map((s, i) => (
|
||||
<motion.button
|
||||
key={s.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
onClick={() => setSelectedSnippet(s)}
|
||||
className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-8 h-8 rounded-xl bg-stone-50 dark:bg-stone-800 flex items-center justify-center text-stone-400 group-hover:text-liquid-purple transition-colors">
|
||||
<Hash size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">{s.category}</span>
|
||||
</div>
|
||||
<h3 className="text-2xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-4 group-hover:text-liquid-purple transition-colors">{s.title}</h3>
|
||||
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed">
|
||||
{s.description}
|
||||
</p>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Snippet Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedSnippet && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedSnippet(null)}
|
||||
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<div className="p-8 md:p-10 overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
|
||||
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedSnippet(null)}
|
||||
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
|
||||
{selectedSnippet.description}
|
||||
</p>
|
||||
|
||||
<div className="relative group/code">
|
||||
<div className="absolute top-4 right-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||
title="Copy Code"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
||||
<code>{selectedSnippet.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
Close Laboratory
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/[locale]/snippets/page.tsx
Normal file
41
app/[locale]/snippets/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
|
||||
import React from "react";
|
||||
import { getSnippets } from "@/lib/directus";
|
||||
import { Terminal, ArrowLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import SnippetsClient from "./SnippetsClient";
|
||||
|
||||
export default async function SnippetsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const snippets = await getSnippets(100) || [];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-all mb-12 group"
|
||||
>
|
||||
<ArrowLeft size={14} className="group-hover:-translate-x-1 transition-transform" />
|
||||
Back to Portfolio
|
||||
</Link>
|
||||
|
||||
<header className="mb-20">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900">
|
||||
<Terminal size={24} />
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
||||
The Lab<span className="text-liquid-purple">.</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-2xl leading-relaxed">
|
||||
A collection of technical snippets, configurations, and mental notes from my daily building process.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<SnippetsClient initialSnippets={snippets} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { GET } from "@/app/api/book-reviews/route";
|
||||
|
||||
// Mock the route handler module
|
||||
@@ -12,7 +12,7 @@ describe("GET /api/book-reviews", () => {
|
||||
NextResponse.json({ bookReviews: [{ id: 1, book_title: "Test" }] })
|
||||
);
|
||||
|
||||
const response = await GET({} as any);
|
||||
const response = await GET({} as NextRequest);
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.bookReviews).toHaveLength(1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { GET } from "@/app/api/hobbies/route";
|
||||
|
||||
// Mock the route handler module
|
||||
@@ -12,7 +12,7 @@ describe("GET /api/hobbies", () => {
|
||||
NextResponse.json({ hobbies: [{ id: 1, title: "Gaming" }] })
|
||||
);
|
||||
|
||||
const response = await GET({} as any);
|
||||
const response = await GET({} as NextRequest);
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.hobbies).toHaveLength(1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { GET } from "@/app/api/tech-stack/route";
|
||||
|
||||
// Mock the route handler module
|
||||
@@ -12,7 +12,7 @@ describe("GET /api/tech-stack", () => {
|
||||
NextResponse.json({ techStack: [{ id: 1, name: "Frontend" }] })
|
||||
);
|
||||
|
||||
const response = await GET({} as any);
|
||||
const response = await GET({} as NextRequest);
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.techStack).toHaveLength(1);
|
||||
|
||||
@@ -64,7 +64,8 @@ describe('ActivityFeed NaN Handling', () => {
|
||||
|
||||
// In the actual code, we use String(data.gaming.name || '')
|
||||
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
||||
const nanName = String(NaN || '');
|
||||
const nanValue = NaN;
|
||||
const nanName = String(nanValue || '');
|
||||
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
||||
expect(typeof nanName).toBe('string');
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ jest.mock("next-intl", () => ({
|
||||
// Mock next/image
|
||||
jest.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img {...props} />,
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
||||
}));
|
||||
|
||||
describe("CurrentlyReading Component", () => {
|
||||
@@ -28,19 +28,17 @@ describe("CurrentlyReading Component", () => {
|
||||
it("renders a book when data is fetched", async () => {
|
||||
const mockBooks = [
|
||||
{
|
||||
id: "1",
|
||||
book_title: "Test Book",
|
||||
book_author: "Test Author",
|
||||
book_image: "/test.jpg",
|
||||
status: "reading",
|
||||
rating: 5,
|
||||
progress: 50
|
||||
title: "Test Book",
|
||||
authors: ["Test Author"],
|
||||
image: "/test.jpg",
|
||||
progress: 50,
|
||||
startedAt: "2024-01-01"
|
||||
},
|
||||
];
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ hardcover: mockBooks }),
|
||||
json: async () => ({ currentlyReading: mockBooks }),
|
||||
});
|
||||
|
||||
render(<CurrentlyReadingComp />);
|
||||
|
||||
@@ -14,9 +14,17 @@ jest.mock('next-intl', () => ({
|
||||
}));
|
||||
|
||||
// Mock next/image
|
||||
interface ImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
fill?: boolean;
|
||||
priority?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
jest.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: ({ src, alt, fill, priority, ...props }: any) => (
|
||||
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
|
||||
@@ -18,6 +18,7 @@ jest.mock('next-intl', () => ({
|
||||
describe('NotFound', () => {
|
||||
it('renders the 404 page with the new design text', () => {
|
||||
render(<NotFound />);
|
||||
expect(screen.getByText("Lost in the Liquid.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Page not/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,14 @@ import Projects from "../components/Projects";
|
||||
import Contact from "../components/Contact";
|
||||
import Footer from "../components/Footer";
|
||||
import Script from "next/script";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function HomePage() {
|
||||
useEffect(() => {
|
||||
// Force scroll to top on mount to prevent starting at lower sections
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Script
|
||||
@@ -33,12 +39,12 @@ export default function HomePage() {
|
||||
/>
|
||||
<Header />
|
||||
{/* Spacer to prevent navbar overlap */}
|
||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
|
||||
<main className="relative">
|
||||
<Hero />
|
||||
|
||||
{/* Wavy Separator 1 - Hero to About */}
|
||||
<div className="relative h-24 overflow-hidden">
|
||||
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 1440 120"
|
||||
@@ -61,7 +67,7 @@ export default function HomePage() {
|
||||
<About />
|
||||
|
||||
{/* Wavy Separator 2 - About to Projects */}
|
||||
<div className="relative h-24 overflow-hidden">
|
||||
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 1440 120"
|
||||
@@ -84,7 +90,7 @@ export default function HomePage() {
|
||||
<Projects />
|
||||
|
||||
{/* Wavy Separator 3 - Projects to Contact */}
|
||||
<div className="relative h-24 overflow-hidden">
|
||||
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
viewBox="0 0 1440 120"
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, ArrowLeft, Github as GithubIcon, Calendar } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Skeleton } from "../components/ui/Skeleton";
|
||||
|
||||
export type ProjectDetailData = {
|
||||
id: number;
|
||||
@@ -48,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();
|
||||
|
||||
@@ -9,13 +9,14 @@ import Image from "next/image";
|
||||
import { Skeleton } from "../components/ui/Skeleton";
|
||||
|
||||
export type ProjectListItem = {
|
||||
id: number;
|
||||
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
date?: string;
|
||||
createdAt?: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma, projectService } from '@/lib/prisma';
|
||||
import { analyticsCache } from '@/lib/redis';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting - more generous for admin dashboard
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 20, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Admin-only endpoint: require explicit admin header AND a valid signed session token
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Check cache first (but allow bypass with cache-bust parameter)
|
||||
const url = new URL(request.url);
|
||||
const bypassCache = url.searchParams.get('nocache') === 'true';
|
||||
|
||||
if (!bypassCache) {
|
||||
const cachedStats = await analyticsCache.getOverallStats();
|
||||
if (cachedStats) {
|
||||
return NextResponse.json(cachedStats);
|
||||
}
|
||||
}
|
||||
|
||||
// Get analytics data
|
||||
const projectsResult = await projectService.getAllProjects();
|
||||
const projects = projectsResult.projects || projectsResult;
|
||||
const performanceStats = await projectService.getPerformanceStats();
|
||||
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Use DB aggregation instead of loading every PageView row into memory
|
||||
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
|
||||
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
|
||||
prisma.pageView.groupBy({
|
||||
by: ['ip'],
|
||||
where: {
|
||||
timestamp: { gte: since },
|
||||
ip: { not: null },
|
||||
},
|
||||
_count: { _all: true },
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
}),
|
||||
prisma.pageView.groupBy({
|
||||
by: ['projectId'],
|
||||
where: {
|
||||
timestamp: { gte: since },
|
||||
projectId: { not: null },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalSessions = sessionsByIp.length;
|
||||
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
|
||||
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
||||
|
||||
const sessionDurationsMs = sessionsByIp
|
||||
.map(s => {
|
||||
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
||||
if (count < 2) return 0;
|
||||
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
|
||||
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
|
||||
if (!minTs || !maxTs) return 0;
|
||||
return maxTs.getTime() - minTs.getTime();
|
||||
})
|
||||
.filter(ms => ms > 0);
|
||||
|
||||
const avgSessionDuration = sessionDurationsMs.length > 0
|
||||
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
|
||||
: 0;
|
||||
|
||||
const totalUsers = totalSessions;
|
||||
|
||||
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
|
||||
const projectId = row.projectId as number | null;
|
||||
if (projectId != null) {
|
||||
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<number, number>);
|
||||
|
||||
// Calculate analytics metrics
|
||||
const analytics = {
|
||||
overview: {
|
||||
totalProjects: projects.length,
|
||||
publishedProjects: projects.filter(p => p.published).length,
|
||||
featuredProjects: projects.filter(p => p.featured).length,
|
||||
totalViews, // Real views from PageView table
|
||||
totalLikes: 0, // Not implemented - no like buttons
|
||||
totalShares: 0, // Not implemented - no share buttons
|
||||
avgLighthouse: (() => {
|
||||
// Only calculate if we have real performance data (not defaults)
|
||||
const projectsWithPerf = projects.filter(p => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
const lighthouse = perf.lighthouse as number || 0;
|
||||
return lighthouse > 0; // Only count projects with actual performance data
|
||||
});
|
||||
return projectsWithPerf.length > 0
|
||||
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
||||
: 0;
|
||||
})()
|
||||
},
|
||||
projects: projects.map(project => ({
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
category: project.category,
|
||||
difficulty: project.difficulty,
|
||||
views: viewsByProject[project.id] || 0, // Only real views from PageView table
|
||||
likes: 0, // Not implemented
|
||||
shares: 0, // Not implemented
|
||||
lighthouse: (() => {
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
const score = perf.lighthouse as number || 0;
|
||||
return score > 0 ? score : 0; // Only return if we have real data
|
||||
})(),
|
||||
published: project.published,
|
||||
featured: project.featured,
|
||||
createdAt: project.createdAt,
|
||||
updatedAt: project.updatedAt
|
||||
})),
|
||||
categories: performanceStats.byCategory,
|
||||
difficulties: performanceStats.byDifficulty,
|
||||
performance: {
|
||||
avgLighthouse: (() => {
|
||||
const projectsWithPerf = projects.filter(p => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
return (perf.lighthouse as number || 0) > 0;
|
||||
});
|
||||
return projectsWithPerf.length > 0
|
||||
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
||||
: 0;
|
||||
})(),
|
||||
totalViews, // Real total views
|
||||
totalLikes: 0,
|
||||
totalShares: 0
|
||||
},
|
||||
metrics: {
|
||||
bounceRate,
|
||||
avgSessionDuration,
|
||||
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
|
||||
newUsers: totalUsers,
|
||||
totalUsers
|
||||
}
|
||||
};
|
||||
|
||||
// Cache the results
|
||||
await analyticsCache.setOverallStats(analytics);
|
||||
|
||||
return NextResponse.json(analytics);
|
||||
} catch (error) {
|
||||
console.error('Analytics dashboard error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch analytics data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { requireSessionAuth } from '@/lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Admin-only endpoint
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
// Get performance data from database
|
||||
const pageViews = await prisma.pageView.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1000 // Last 1000 page views
|
||||
});
|
||||
|
||||
const userInteractions = await prisma.userInteraction.findMany({
|
||||
orderBy: { timestamp: 'desc' },
|
||||
take: 1000 // Last 1000 interactions
|
||||
});
|
||||
|
||||
// Get all projects for performance data
|
||||
const projects = await prisma.project.findMany();
|
||||
|
||||
// Calculate real performance metrics from projects
|
||||
const projectsWithPerformance = projects.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
lighthouse: ((p.performance as Record<string, unknown>)?.lighthouse as number) || 0,
|
||||
loadTime: ((p.performance as Record<string, unknown>)?.loadTime as number) || 0,
|
||||
fcp: ((p.performance as Record<string, unknown>)?.firstContentfulPaint as number) || 0,
|
||||
lcp: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.lcp as number || 0,
|
||||
cls: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.cls as number || 0
|
||||
}));
|
||||
|
||||
// Calculate average lighthouse score (currently unused but kept for future use)
|
||||
const _avgLighthouse = projectsWithPerformance.length > 0
|
||||
? Math.round(projectsWithPerformance.reduce((sum, p) => sum + p.lighthouse, 0) / projectsWithPerformance.length)
|
||||
: 0;
|
||||
|
||||
// Calculate bounce rate from page views
|
||||
const pageViewsByIP = pageViews.reduce((acc, pv) => {
|
||||
const ip = pv.ip || 'unknown';
|
||||
if (!acc[ip]) acc[ip] = [];
|
||||
acc[ip].push(pv);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof pageViews>);
|
||||
|
||||
const totalSessions = Object.keys(pageViewsByIP).length;
|
||||
const bouncedSessions = Object.values(pageViewsByIP).filter(session => session.length === 1).length;
|
||||
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
||||
|
||||
// Calculate average session duration
|
||||
const sessionDurations = Object.values(pageViewsByIP)
|
||||
.map(session => {
|
||||
if (session.length < 2) return 0;
|
||||
const sorted = session.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
return sorted[sorted.length - 1].timestamp.getTime() - sorted[0].timestamp.getTime();
|
||||
})
|
||||
.filter(d => d > 0);
|
||||
const avgSessionDuration = sessionDurations.length > 0
|
||||
? Math.round(sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length / 1000) // in seconds
|
||||
: 0;
|
||||
|
||||
// Calculate pages per session
|
||||
const pagesPerSession = totalSessions > 0 ? (pageViews.length / totalSessions).toFixed(1) : '0';
|
||||
|
||||
// Calculate performance metrics
|
||||
const performance = {
|
||||
avgLighthouse: (() => {
|
||||
const projectsWithPerf = projects.filter(p => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
return (perf.lighthouse as number || 0) > 0;
|
||||
});
|
||||
return projectsWithPerf.length > 0
|
||||
? Math.round(projectsWithPerf.reduce((sum, p) => {
|
||||
const perf = (p.performance as Record<string, unknown>) || {};
|
||||
return sum + (perf.lighthouse as number || 0);
|
||||
}, 0) / projectsWithPerf.length)
|
||||
: 0;
|
||||
})(),
|
||||
totalViews: pageViews.length,
|
||||
metrics: {
|
||||
bounceRate,
|
||||
avgSessionDuration: avgSessionDuration,
|
||||
pagesPerSession: parseFloat(pagesPerSession),
|
||||
newUsers: new Set(pageViews.map(pv => pv.ip).filter(Boolean)).size
|
||||
},
|
||||
pageViews: {
|
||||
total: pageViews.length,
|
||||
last24h: pageViews.filter(pv => {
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return new Date(pv.timestamp) > dayAgo;
|
||||
}).length,
|
||||
last7d: pageViews.filter(pv => {
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(pv.timestamp) > weekAgo;
|
||||
}).length,
|
||||
last30d: pageViews.filter(pv => {
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
return new Date(pv.timestamp) > monthAgo;
|
||||
}).length
|
||||
},
|
||||
interactions: {
|
||||
total: userInteractions.length,
|
||||
last24h: userInteractions.filter(ui => {
|
||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
return new Date(ui.timestamp) > dayAgo;
|
||||
}).length,
|
||||
last7d: userInteractions.filter(ui => {
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(ui.timestamp) > weekAgo;
|
||||
}).length,
|
||||
last30d: userInteractions.filter(ui => {
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
return new Date(ui.timestamp) > monthAgo;
|
||||
}).length
|
||||
},
|
||||
topPages: pageViews.reduce((acc, pv) => {
|
||||
acc[pv.page] = (acc[pv.page] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
topInteractions: userInteractions.reduce((acc, ui) => {
|
||||
acc[ui.type] = (acc[ui.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
};
|
||||
|
||||
return NextResponse.json(performance);
|
||||
} catch (error) {
|
||||
console.error('Performance analytics error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch performance data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { analyticsCache } from '@/lib/redis';
|
||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 3, 300000)) { // 3 requests per 5 minutes - more restrictive for reset
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 3, 300000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Check admin authentication
|
||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
||||
const authError = requireSessionAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { type } = await request.json();
|
||||
|
||||
switch (type) {
|
||||
case 'analytics':
|
||||
// Reset all project analytics (view counts in project.analytics JSON)
|
||||
const projects = await prisma.project.findMany();
|
||||
for (const project of projects) {
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
analytics: {
|
||||
...analytics,
|
||||
views: 0,
|
||||
likes: 0,
|
||||
shares: 0,
|
||||
comments: 0,
|
||||
bookmarks: 0,
|
||||
clickThroughs: 0,
|
||||
bounceRate: 0,
|
||||
avgTimeOnPage: 0,
|
||||
uniqueVisitors: 0,
|
||||
returningVisitors: 0,
|
||||
conversionRate: 0,
|
||||
socialShares: {
|
||||
twitter: 0,
|
||||
linkedin: 0,
|
||||
facebook: 0,
|
||||
github: 0
|
||||
},
|
||||
deviceStats: {
|
||||
mobile: 0,
|
||||
desktop: 0,
|
||||
tablet: 0
|
||||
},
|
||||
locationStats: {},
|
||||
referrerStats: {},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pageviews':
|
||||
// Clear PageView table
|
||||
await prisma.pageView.deleteMany({});
|
||||
break;
|
||||
|
||||
case 'interactions':
|
||||
// Clear UserInteraction table
|
||||
await prisma.userInteraction.deleteMany({});
|
||||
break;
|
||||
|
||||
case 'performance':
|
||||
// Reset performance metrics (preserve structure)
|
||||
const projectsForPerf = await prisma.project.findMany();
|
||||
for (const project of projectsForPerf) {
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
performance: {
|
||||
...perf,
|
||||
lighthouse: 0,
|
||||
loadTime: 0,
|
||||
firstContentfulPaint: 0,
|
||||
largestContentfulPaint: 0,
|
||||
cumulativeLayoutShift: 0,
|
||||
totalBlockingTime: 0,
|
||||
speedIndex: 0,
|
||||
accessibility: 0,
|
||||
bestPractices: 0,
|
||||
seo: 0,
|
||||
performanceScore: 0,
|
||||
mobileScore: 0,
|
||||
desktopScore: 0,
|
||||
coreWebVitals: {
|
||||
lcp: 0,
|
||||
fid: 0,
|
||||
cls: 0
|
||||
},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
// Reset everything
|
||||
const allProjects = await prisma.project.findMany();
|
||||
await Promise.all([
|
||||
// Reset analytics and performance for each project (preserve structure)
|
||||
...allProjects.map(project => {
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
return prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
analytics: {
|
||||
...analytics,
|
||||
views: 0,
|
||||
likes: 0,
|
||||
shares: 0,
|
||||
comments: 0,
|
||||
bookmarks: 0,
|
||||
clickThroughs: 0,
|
||||
bounceRate: 0,
|
||||
avgTimeOnPage: 0,
|
||||
uniqueVisitors: 0,
|
||||
returningVisitors: 0,
|
||||
conversionRate: 0,
|
||||
socialShares: {
|
||||
twitter: 0,
|
||||
linkedin: 0,
|
||||
facebook: 0,
|
||||
github: 0
|
||||
},
|
||||
deviceStats: {
|
||||
mobile: 0,
|
||||
desktop: 0,
|
||||
tablet: 0
|
||||
},
|
||||
locationStats: {},
|
||||
referrerStats: {},
|
||||
lastUpdated: new Date().toISOString()
|
||||
},
|
||||
performance: {
|
||||
...perf,
|
||||
lighthouse: 0,
|
||||
loadTime: 0,
|
||||
firstContentfulPaint: 0,
|
||||
largestContentfulPaint: 0,
|
||||
cumulativeLayoutShift: 0,
|
||||
totalBlockingTime: 0,
|
||||
speedIndex: 0,
|
||||
accessibility: 0,
|
||||
bestPractices: 0,
|
||||
seo: 0,
|
||||
performanceScore: 0,
|
||||
mobileScore: 0,
|
||||
desktopScore: 0,
|
||||
coreWebVitals: {
|
||||
lcp: 0,
|
||||
fid: 0,
|
||||
cls: 0
|
||||
},
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
// Clear tracking tables
|
||||
prisma.pageView.deleteMany({}),
|
||||
prisma.userInteraction.deleteMany({})
|
||||
]);
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid reset type. Use: analytics, pageviews, interactions, performance, or all' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Clear cache
|
||||
await analyticsCache.clearAll();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully reset ${type} data`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Analytics reset error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to reset analytics data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting for POST requests
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for analytics
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 30, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Log performance metrics (you can extend this to store in database)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Performance Metric:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
...body,
|
||||
});
|
||||
}
|
||||
|
||||
// You could store this in a database or send to external service
|
||||
// For now, we'll just log it since Umami handles the main analytics
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Analytics API Error:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process analytics data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
message: 'Analytics API is running',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/prisma';
|
||||
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
||||
if (!checkRateLimit(ip, 100, 60000)) { // 100 requests per minute for tracking
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getRateLimitHeaders(ip, 100, 60000)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, projectId, page, performance, session } = body;
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
const referrer = request.headers.get('referer') || undefined;
|
||||
|
||||
// Track page view
|
||||
if (type === 'pageview' && page) {
|
||||
let projectIdNum: number | null = null;
|
||||
if (projectId != null) {
|
||||
const raw = projectId.toString();
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
projectIdNum = parsed;
|
||||
} else {
|
||||
const bySlug = await prisma.project.findFirst({
|
||||
where: { slug: raw },
|
||||
select: { id: true },
|
||||
});
|
||||
projectIdNum = bySlug?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// Create page view record
|
||||
await prisma.pageView.create({
|
||||
data: {
|
||||
projectId: projectIdNum,
|
||||
page,
|
||||
ip,
|
||||
userAgent,
|
||||
referrer
|
||||
}
|
||||
});
|
||||
|
||||
// Update project analytics if projectId exists
|
||||
if (projectIdNum) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectIdNum }
|
||||
});
|
||||
|
||||
if (project) {
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
const currentViews = (analytics.views as number) || 0;
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id: projectIdNum },
|
||||
data: {
|
||||
analytics: {
|
||||
...analytics,
|
||||
views: currentViews + 1,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track performance metrics
|
||||
if (type === 'performance' && performance) {
|
||||
// Try to get projectId from page path if not provided
|
||||
let projectIdNum: number | null = null;
|
||||
if (projectId) {
|
||||
projectIdNum = parseInt(projectId.toString());
|
||||
} else if (page) {
|
||||
// Try to extract from page path like /projects/123 or /projects/slug
|
||||
const match = page.match(/\/projects\/(\d+)/);
|
||||
if (match) {
|
||||
projectIdNum = parseInt(match[1]);
|
||||
} else {
|
||||
// Try to find by slug
|
||||
const slugMatch = page.match(/\/projects\/([^\/]+)/);
|
||||
if (slugMatch) {
|
||||
const slug = slugMatch[1];
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: parseInt(slug) || 0 },
|
||||
{ slug }
|
||||
]
|
||||
}
|
||||
});
|
||||
if (project) projectIdNum = project.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (projectIdNum) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectIdNum }
|
||||
});
|
||||
|
||||
if (project) {
|
||||
const perf = (project.performance as Record<string, unknown>) || {};
|
||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
||||
|
||||
// Calculate lighthouse score from web vitals
|
||||
const lcp = performance.lcp || 0;
|
||||
const fid = performance.fid || 0;
|
||||
const cls = performance.cls || 0;
|
||||
const fcp = performance.fcp || 0;
|
||||
const ttfb = performance.ttfb || 0;
|
||||
|
||||
// Only calculate lighthouse score if we have real web vitals data
|
||||
// Check if we have at least LCP and FCP (most important metrics)
|
||||
if (lcp > 0 || fcp > 0) {
|
||||
// Simple lighthouse score calculation (0-100)
|
||||
let lighthouseScore = 100;
|
||||
if (lcp > 4000) lighthouseScore -= 25;
|
||||
else if (lcp > 2500) lighthouseScore -= 15;
|
||||
if (fid > 300) lighthouseScore -= 25;
|
||||
else if (fid > 100) lighthouseScore -= 15;
|
||||
if (cls > 0.25) lighthouseScore -= 25;
|
||||
else if (cls > 0.1) lighthouseScore -= 15;
|
||||
if (fcp > 3000) lighthouseScore -= 15;
|
||||
if (ttfb > 800) lighthouseScore -= 10;
|
||||
|
||||
lighthouseScore = Math.max(0, Math.min(100, lighthouseScore));
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id: projectIdNum },
|
||||
data: {
|
||||
performance: {
|
||||
...perf,
|
||||
lighthouse: lighthouseScore,
|
||||
loadTime: performance.loadTime || perf.loadTime || 0,
|
||||
firstContentfulPaint: fcp || perf.firstContentfulPaint || 0,
|
||||
largestContentfulPaint: lcp || perf.largestContentfulPaint || 0,
|
||||
cumulativeLayoutShift: cls || perf.cumulativeLayoutShift || 0,
|
||||
totalBlockingTime: performance.tbt || perf.totalBlockingTime || 0,
|
||||
speedIndex: performance.si || perf.speedIndex || 0,
|
||||
coreWebVitals: {
|
||||
lcp: lcp || (perf.coreWebVitals as Record<string, unknown>)?.lcp || 0,
|
||||
fid: fid || (perf.coreWebVitals as Record<string, unknown>)?.fid || 0,
|
||||
cls: cls || (perf.coreWebVitals as Record<string, unknown>)?.cls || 0
|
||||
},
|
||||
lastUpdated: new Date().toISOString()
|
||||
},
|
||||
analytics: {
|
||||
...analytics,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track session data (for bounce rate calculation)
|
||||
if (type === 'session' && session) {
|
||||
// Store session data in a way that allows bounce rate calculation
|
||||
// A bounce is a session with only one pageview
|
||||
// We'll track this via PageView records and calculate bounce rate from them
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Analytics tracking error:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to track analytics' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { getBookReviews } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* GET /api/book-reviews
|
||||
@@ -31,25 +33,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (reviews && reviews.length > 0) {
|
||||
return NextResponse.json({
|
||||
bookReviews: reviews,
|
||||
source: 'directus'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ bookReviews: reviews, source: 'directus' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
bookReviews: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ bookReviews: null, source: 'fallback' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error loading book reviews:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
bookReviews: null,
|
||||
error: 'Failed to load book reviews',
|
||||
source: 'error'
|
||||
},
|
||||
{ bookReviews: null, error: 'Failed to load book reviews', source: 'error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getContentByKey } from "@/lib/content";
|
||||
import { getContentPage } from "@/lib/directus";
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get("key");
|
||||
@@ -15,7 +17,8 @@ export async function GET(request: NextRequest) {
|
||||
// 1) Try Directus first
|
||||
const directusPage = await getContentPage(key, locale);
|
||||
if (directusPage) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
content: {
|
||||
title: directusPage.title,
|
||||
slug: directusPage.slug,
|
||||
@@ -23,13 +26,23 @@ export async function GET(request: NextRequest) {
|
||||
content: directusPage.content,
|
||||
},
|
||||
source: "directus",
|
||||
});
|
||||
},
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Fallback: PostgreSQL
|
||||
const translation = await getContentByKey({ key, locale });
|
||||
if (!translation) return NextResponse.json({ content: null });
|
||||
return NextResponse.json({ content: translation, source: "postgresql" });
|
||||
if (!translation) {
|
||||
return NextResponse.json(
|
||||
{ content: null },
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ content: translation, source: "postgresql" },
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
} catch (error) {
|
||||
// If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { getHobbies } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* GET /api/hobbies
|
||||
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
|
||||
const hobbies = await getHobbies(locale);
|
||||
|
||||
if (hobbies && hobbies.length > 0) {
|
||||
return NextResponse.json({
|
||||
hobbies,
|
||||
source: 'directus'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ hobbies, source: 'directus' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: return empty (component will use hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
hobbies: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ hobbies: null, source: 'fallback' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error loading hobbies:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
hobbies: null,
|
||||
error: 'Failed to load hobbies',
|
||||
source: 'error'
|
||||
},
|
||||
{ hobbies: null, error: 'Failed to load hobbies', source: 'error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Flatten das Objekt zu flachen Keys
|
||||
const flatKeys = flattenObject(namespaceData);
|
||||
const flatKeys = flattenObject(namespaceData as Record<string, unknown>);
|
||||
|
||||
// Lade jeden Key aus Directus (mit Fallback auf JSON)
|
||||
const result: Record<string, string> = {};
|
||||
@@ -57,19 +57,24 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Helper: Holt verschachtelte Werte aus Objekt
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce<unknown>((current, key) => {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
// Helper: Flatten verschachteltes Objekt zu flachen Keys
|
||||
function flattenObject(obj: any, prefix = ''): Record<string, string> {
|
||||
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
Object.assign(result, flattenObject(value, newKey));
|
||||
Object.assign(result, flattenObject(value as Record<string, unknown>, newKey));
|
||||
} else {
|
||||
result[newKey] = String(value);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
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 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ messages },
|
||||
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
} catch {
|
||||
return NextResponse.json({ messages: {} }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
||||
: ip;
|
||||
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
|
||||
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
|
||||
: ip;
|
||||
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
|
||||
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
|
||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp }
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
import { generateUniqueSlug } from '@/lib/slug';
|
||||
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
||||
import { ProjectListItem } from '@/app/_ui/ProjectsPageClient';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -41,17 +42,18 @@ export async function GET(request: NextRequest) {
|
||||
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
||||
const category = searchParams.get('category');
|
||||
const featured = searchParams.get('featured');
|
||||
const published = searchParams.get('published');
|
||||
const published = searchParams.get('published') === 'false' ? false : true; // Default to true if not specified
|
||||
const difficulty = searchParams.get('difficulty');
|
||||
const search = searchParams.get('search');
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
|
||||
// Try Directus FIRST (Primary Source)
|
||||
let directusProjects: any[] = [];
|
||||
let directusProjects: ProjectListItem[] = [];
|
||||
let directusSuccess = false;
|
||||
try {
|
||||
const fetched = await getDirectusProjects(locale, {
|
||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
||||
published: published,
|
||||
category: category || undefined,
|
||||
difficulty: difficulty || undefined,
|
||||
search: search || undefined,
|
||||
@@ -59,18 +61,25 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (fetched) {
|
||||
directusProjects = fetched;
|
||||
directusProjects = fetched.map(p => ({
|
||||
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
tags: p.tags || [],
|
||||
category: p.category || '',
|
||||
date: p.created_at,
|
||||
createdAt: p.created_at,
|
||||
imageUrl: p.image_url,
|
||||
}));
|
||||
directusSuccess = true;
|
||||
}
|
||||
} catch (directusError) {
|
||||
console.log('Directus error, continuing with PostgreSQL');
|
||||
} catch {
|
||||
console.log('Directus error, continuing with PostgreSQL fallback');
|
||||
}
|
||||
|
||||
// Fallback 1: Try PostgreSQL
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch (dbError) {
|
||||
console.log('PostgreSQL not available');
|
||||
if (directusProjects.length > 0) {
|
||||
// If Directus returned projects, use them EXCLUSIVELY to avoid showing un-synced local data
|
||||
if (directusSuccess && directusProjects.length > 0) {
|
||||
return NextResponse.json({
|
||||
projects: directusProjects,
|
||||
total: directusProjects.length,
|
||||
@@ -78,10 +87,15 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback 1: Try PostgreSQL only if Directus failed or is empty
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
console.log('PostgreSQL not available');
|
||||
return NextResponse.json({
|
||||
projects: [],
|
||||
total: 0,
|
||||
source: 'fallback'
|
||||
projects: directusProjects, // Might be empty
|
||||
total: directusProjects.length,
|
||||
source: 'directus-empty'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +104,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (category) where.category = category;
|
||||
if (featured !== null) where.featured = featured === 'true';
|
||||
if (published !== null) where.published = published === 'true';
|
||||
where.published = published;
|
||||
if (difficulty) where.difficulty = difficulty;
|
||||
|
||||
if (search) {
|
||||
@@ -113,7 +127,17 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Merge logic
|
||||
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
||||
const mergedProjects = [...dbProjects];
|
||||
const mergedProjects: ProjectListItem[] = dbProjects.map(p => ({
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
tags: p.tags,
|
||||
category: p.category,
|
||||
date: p.date,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
imageUrl: p.imageUrl,
|
||||
}));
|
||||
|
||||
for (const dp of directusProjects) {
|
||||
if (!dbSlugs.has(dp.slug)) {
|
||||
|
||||
21
app/api/snippets/route.ts
Normal file
21
app/api/snippets/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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);
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const featured = searchParams.get('featured') === 'true' ? true : undefined;
|
||||
|
||||
const snippets = await getSnippets(limit, featured);
|
||||
|
||||
return NextResponse.json(
|
||||
{ snippets: snippets || [] },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
} catch (_error) {
|
||||
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { getTechStack } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 300;
|
||||
|
||||
const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* GET /api/tech-stack
|
||||
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
|
||||
const techStack = await getTechStack(locale);
|
||||
|
||||
if (techStack && techStack.length > 0) {
|
||||
return NextResponse.json({
|
||||
techStack,
|
||||
source: 'directus'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ techStack, source: 'directus' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: return empty (component will use hardcoded fallback)
|
||||
return NextResponse.json({
|
||||
techStack: null,
|
||||
source: 'fallback'
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ techStack: null, source: 'fallback' },
|
||||
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error loading tech stack:', error);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
techStack: null,
|
||||
error: 'Failed to load tech stack',
|
||||
source: 'error'
|
||||
},
|
||||
{ techStack: null, error: 'Failed to load tech stack', source: 'error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight } from "lucide-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 } from "framer-motion";
|
||||
import { TechStackCategory, Hobby } from "@/lib/directus";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { TechStackCategory, TechStackItem, Hobby, Snippet } from "@/lib/directus";
|
||||
import Link from "next/link";
|
||||
import ActivityFeed from "./ActivityFeed";
|
||||
import BentoChat from "./BentoChat";
|
||||
import { Skeleton } from "./ui/Skeleton";
|
||||
import { LucideIcon, X, Copy, Check } from "lucide-react";
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
||||
};
|
||||
|
||||
const About = () => {
|
||||
@@ -24,19 +26,21 @@ const About = () => {
|
||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||
const [reviewsCount, setReviewsCount] = useState(0);
|
||||
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [cmsRes, techRes, hobbiesRes, msgRes, booksRes] = 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`)
|
||||
]);
|
||||
|
||||
const cmsData = await cmsRes.json();
|
||||
@@ -51,8 +55,8 @@ const About = () => {
|
||||
const msgData = await msgRes.json();
|
||||
if (msgData?.messages) setCmsMessages(msgData.messages);
|
||||
|
||||
const booksData = await booksRes.json();
|
||||
if (booksData?.bookReviews) setReviewsCount(booksData.bookReviews.length);
|
||||
const snippetsData = await snippetsRes.json();
|
||||
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
||||
} catch (error) {
|
||||
console.error("About data fetch failed:", error);
|
||||
} finally {
|
||||
@@ -62,24 +66,30 @@ const About = () => {
|
||||
fetchData();
|
||||
}, [locale]);
|
||||
|
||||
const copyToClipboard = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="about" className="py-32 px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||
|
||||
{/* 1. Large Bio Text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
||||
{t("title")}<span className="text-liquid-mint">.</span>
|
||||
<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-emerald-600 dark:text-emerald-400">.</span>
|
||||
</h2>
|
||||
<div className="prose prose-stone dark:prose-invert max-w-none text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||
<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 ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
@@ -92,10 +102,10 @@ const About = () => {
|
||||
<p>{t("p1")} {t("p2")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-8">
|
||||
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-8 py-4 rounded-3xl border border-stone-100 dark:border-stone-700">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-2">{t("funFactTitle")}</p>
|
||||
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
||||
<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-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>
|
||||
</div>
|
||||
@@ -107,13 +117,13 @@ const About = () => {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="md:col-span-4 bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
||||
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
||||
>
|
||||
<div className="relative z-10 h-full">
|
||||
<h3 className="text-xl font-black mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
||||
<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"]} />
|
||||
<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>
|
||||
@@ -124,11 +134,11 @@ const About = () => {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
|
||||
className="md:col-span-12 lg:col-span-4 bg-stone-50 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 flex flex-col shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<MessageSquare className="text-liquid-purple" size={24} />
|
||||
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
|
||||
<div className="flex items-center gap-2 mb-5 sm:mb-8">
|
||||
<MessageSquare className="text-liquid-purple" size={20} />
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<BentoChat />
|
||||
@@ -141,9 +151,9 @@ const About = () => {
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-12">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="space-y-6">
|
||||
@@ -158,9 +168,9 @@ 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: any) => (
|
||||
{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">
|
||||
{item.name}
|
||||
</span>
|
||||
@@ -172,55 +182,205 @@ const About = () => {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 5. Library & Hobbies */}
|
||||
{/* 5. Library, Gear & Snippets */}
|
||||
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||
{/* Library - Larger Span */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="md:col-span-12 grid grid-cols-1 md:grid-cols-2 gap-8"
|
||||
className="lg:col-span-7 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 flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
||||
>
|
||||
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative">
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-3 uppercase tracking-tighter">
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
|
||||
<BookOpen className="text-liquid-purple" size={24} /> Library
|
||||
</h3>
|
||||
<Link href={`/${locale}/books`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
||||
View All <ArrowRight size={14} className="group-hover:translate-x-1 transition-transform" />
|
||||
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
||||
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
<CurrentlyReading />
|
||||
<div className="mt-6">
|
||||
<div className="mt-6 flex-1">
|
||||
<ReadBooks />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
|
||||
{/* My Gear (Uses) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
|
||||
<Cpu className="text-liquid-mint" size={24} /> My Gear
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:gap-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
|
||||
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
|
||||
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
|
||||
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
|
||||
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.6 }}
|
||||
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 flex-col justify-between group overflow-hidden relative flex-1"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter mb-4 sm:mb-6">
|
||||
<Terminal className="text-liquid-purple" size={24} /> Snippets
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-12 rounded-xl" />)
|
||||
) : snippets.length > 0 ? (
|
||||
snippets.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
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-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-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-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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
|
||||
<div className="flex flex-wrap gap-4 mb-10">
|
||||
{/* 6. Hobbies */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="md:col-span-12"
|
||||
>
|
||||
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="w-12 h-12 rounded-2xl" />)
|
||||
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||
) : (
|
||||
hobbies.map((hobby) => {
|
||||
const Icon = iconMap[hobby.icon] || Lightbulb;
|
||||
return (
|
||||
<div key={hobby.id} className="w-12 h-12 rounded-2xl bg-stone-50 dark:bg-stone-800 flex items-center justify-center shadow-sm border border-stone-100 dark:border-stone-700">
|
||||
<Icon size={20} className="text-liquid-mint" />
|
||||
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
|
||||
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0 sm:w-5 sm:h-5" />
|
||||
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
|
||||
</div>
|
||||
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
|
||||
{hobby.description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50">{t("hobbiesTitle")}</h3>
|
||||
<p className="text-stone-500 font-light text-lg">Curiosity beyond software engineering.</p>
|
||||
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
|
||||
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snippet Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedSnippet && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedSnippet(null)}
|
||||
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<div className="p-5 sm:p-8 md:p-10 overflow-y-auto">
|
||||
<div className="flex justify-between items-start mb-5 sm:mb-8">
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-1 sm:mb-2">{selectedSnippet.category}</p>
|
||||
<h3 className="text-xl sm:text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedSnippet(null)}
|
||||
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm sm:text-base text-stone-600 dark:text-stone-400 mb-5 sm:mb-8 leading-relaxed">
|
||||
{selectedSnippet.description}
|
||||
</p>
|
||||
|
||||
<div className="relative group/code">
|
||||
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||
className="p-2 sm:p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||
title="Copy Code"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="bg-stone-950 p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-x-auto text-xs sm:text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
||||
<code>{selectedSnippet.code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
<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-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Close Laboratory
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,51 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
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;
|
||||
}
|
||||
|
||||
interface StatusData {
|
||||
music: { isPlaying: boolean; track: string; artist: string; albumArt: string; url: string; } | null;
|
||||
gaming: { isPlaying: boolean; name: string; image: string | null; state?: string | number; details?: string | number; } | null;
|
||||
coding: { isActive: boolean; project?: string; file?: string; language?: string; } | null;
|
||||
customActivities?: Record<string, any>;
|
||||
customActivities?: Record<string, CustomActivity>;
|
||||
}
|
||||
|
||||
const techQuotes = [
|
||||
const techQuotes = {
|
||||
de: [
|
||||
{ content: "Informatik hat nicht mehr mit Computern zu tun als Astronomie mit Teleskopen.", author: "Edsger W. Dijkstra" },
|
||||
{ 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: "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: "There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.", author: "Tony Hoare" },
|
||||
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
|
||||
{ content: "Walking on water and developing software from a specification are easy if both are frozen.", author: "Edward V. Berard" },
|
||||
{ content: "Code never lies, comments sometimes do.", author: "Ron Jeffries" },
|
||||
{ content: "I have no special talent. I am only passionately curious.", author: "Albert Einstein" },
|
||||
{ content: "No code is faster than no code.", author: "Kevlin Henney" },
|
||||
{ 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" },
|
||||
]
|
||||
};
|
||||
|
||||
function getSafeGamingText(details: string | number | undefined, state: string | number | undefined, fallback: string): string {
|
||||
if (typeof details === 'string' && details.trim().length > 0) return details;
|
||||
if (typeof state === 'string' && state.trim().length > 0) return state;
|
||||
if (typeof details === 'number' && !isNaN(details)) return String(details);
|
||||
if (typeof state === 'number' && !isNaN(state)) return String(state);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
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 [randomQuote, setRandomQuote] = useState(techQuotes[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 allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
|
||||
|
||||
useEffect(() => {
|
||||
setRandomQuote(techQuotes[Math.floor(Math.random() * techQuotes.length)]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/n8n/status");
|
||||
@@ -58,73 +82,104 @@ export default function ActivityFeed({
|
||||
activityData.coding?.isActive ||
|
||||
activityData.gaming?.isPlaying ||
|
||||
activityData.music?.isPlaying ||
|
||||
Object.values(activityData.customActivities || {}).some((act: any) => act?.enabled)
|
||||
Object.values(activityData.customActivities || {}).some((act) => Boolean((act as { enabled?: boolean })?.enabled))
|
||||
);
|
||||
setHasActivity(isActive);
|
||||
onActivityChange?.(isActive);
|
||||
} catch {
|
||||
setHasActivity(false);
|
||||
onActivityChange?.(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
const statusInterval = setInterval(fetchData, 30000);
|
||||
|
||||
// Pick a random quote every 10 seconds (never the same one twice in a row)
|
||||
const quoteInterval = setInterval(() => {
|
||||
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]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-pulse space-y-4">
|
||||
<div className="h-24 bg-stone-100 dark:bg-stone-800 rounded-2xl" />
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (!hasActivity) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="h-full flex flex-col justify-center space-y-6"
|
||||
>
|
||||
<div className="h-full flex flex-col justify-center space-y-6">
|
||||
<div className="w-10 h-10 rounded-full bg-liquid-mint/10 flex items-center justify-center">
|
||||
<QuoteIcon size={18} className="text-liquid-mint" />
|
||||
<QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xl md:text-2xl font-light leading-tight text-stone-300 italic">
|
||||
“{idleQuote || randomQuote.content}”
|
||||
<div className="min-h-[80px] sm:min-h-[120px] relative">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={quoteIndex}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
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-300 italic">
|
||||
“{allQuotes[quoteIndex].content}”
|
||||
</p>
|
||||
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
|
||||
— {allQuotes[quoteIndex].author}
|
||||
</p>
|
||||
{!idleQuote && <p className="text-xs font-bold text-stone-500 uppercase tracking-widest">— {randomQuote.author}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-stone-700" /> Currently Thinking
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-600 pt-4 border-t border-stone-100 dark:border-stone-800">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
|
||||
{t("idleStatus")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{data?.coding?.isActive && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-green-500/10 border border-green-500/20 rounded-2xl p-5">
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Zap size={14} className="text-green-400 animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-green-400">Coding Now</span>
|
||||
<Zap size={14} className="text-emerald-600 dark:text-emerald-400 animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span>
|
||||
</div>
|
||||
<p className="font-bold text-white text-lg truncate">{data.coding.project}</p>
|
||||
<p className="text-xs text-white/50 truncate">{data.coding.file}</p>
|
||||
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
|
||||
<p className="text-xs text-stone-500 dark:text-white/50 truncate">{data.coding.file}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{data?.gaming?.isPlaying && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-indigo-500/10 border border-indigo-500/20 rounded-2xl p-5">
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-indigo-500/5 dark:bg-indigo-500/10 border border-indigo-500/20 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Gamepad2 size={14} className="text-indigo-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-indigo-400">Gaming</span>
|
||||
<Gamepad2 size={14} className="text-indigo-600 dark:text-indigo-400" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400">{t("gaming")}</span>
|
||||
</div>
|
||||
<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">
|
||||
<p className="font-bold text-white text-base truncate">{data.gaming.name}</p>
|
||||
<p className="text-xs text-white/50 truncate">
|
||||
{getSafeGamingText(data.gaming.details, data.gaming.state, "In Game")}
|
||||
<p className="font-bold text-stone-900 dark:text-white text-base truncate">{data.gaming.name}</p>
|
||||
<p className="text-xs text-stone-500 dark:text-white/50 truncate">
|
||||
{getSafeGamingText(data.gaming.details, data.gaming.state, t("inGame"))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,20 +187,50 @@ export default function ActivityFeed({
|
||||
)}
|
||||
|
||||
{data?.music?.isPlaying && (
|
||||
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-white/5 border border-white/10 rounded-2xl p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Disc3 size={14} className="text-green-400 animate-spin-slow" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-white/40">Listening</span>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="bg-[#1DB954]/5 dark:bg-[#1DB954]/10 border border-[#1DB954]/20 rounded-2xl p-5 relative overflow-hidden group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3 relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Disc3 size={14} className="text-[#1DB954] animate-spin-slow" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-[#1DB954]">{t("listening")}</span>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-2xl relative">
|
||||
<img src={data.music.albumArt} alt="Album Art" className="w-full h-full object-cover" />
|
||||
{/* Simple Animated Music Bars */}
|
||||
<div className="flex items-end gap-[2px] h-3">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
animate={{ height: ["20%", "100%", "20%"] }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
className="w-[2px] bg-[#1DB954] rounded-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<p className="font-bold text-white text-base truncate">{data.music.track}</p>
|
||||
<p className="text-xs text-white/50 truncate">{data.music.artist}</p>
|
||||
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1">{data.music.track}</p>
|
||||
<p className="text-sm text-stone-600 dark:text-white/60 truncate font-medium">{data.music.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Subtle Spotify branding gradient */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-[#1DB954]/5 blur-[60px] rounded-full -mr-16 -mt-16 pointer-events-none" />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Send, Loader2, MessageSquare, Trash2 } from "lucide-react";
|
||||
import { Send, Loader2 } from "lucide-react";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -11,12 +10,19 @@ interface Message {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface StoredMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
sender: "user" | "bot";
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export default function BentoChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string>("default");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -30,7 +36,7 @@ export default function BentoChat() {
|
||||
|
||||
const storedMsgs = localStorage.getItem("chatMessages");
|
||||
if (storedMsgs) {
|
||||
setMessages(JSON.parse(storedMsgs).map((m: any) => ({ ...m, timestamp: new Date(m.timestamp) })));
|
||||
setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) })));
|
||||
} else {
|
||||
setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]);
|
||||
}
|
||||
@@ -38,8 +44,13 @@ export default function BentoChat() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) localStorage.setItem("chatMessages", JSON.stringify(messages));
|
||||
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
if (messages.length > 0) {
|
||||
localStorage.setItem("chatMessages", JSON.stringify(messages));
|
||||
}
|
||||
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
@@ -67,7 +78,10 @@ export default function BentoChat() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-[300px]">
|
||||
<div className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
|
||||
>
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
|
||||
@@ -80,7 +94,6 @@ export default function BentoChat() {
|
||||
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
@@ -92,7 +105,7 @@ export default function BentoChat() {
|
||||
placeholder="Ask me..."
|
||||
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
|
||||
/>
|
||||
<button onClick={handleSend} className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,20 @@ 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";
|
||||
|
||||
// Dynamic import with SSR disabled to avoid framer-motion issues
|
||||
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
const ShaderGradientBackground = dynamic(
|
||||
() => import("./ShaderGradientBackground"),
|
||||
{ ssr: false, loading: () => null }
|
||||
);
|
||||
|
||||
export default function ClientProviders({
|
||||
children,
|
||||
}: {
|
||||
@@ -70,7 +74,17 @@ export default function ClientProviders({
|
||||
<ConsentProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</GatedProviders>
|
||||
</ThemeProvider>
|
||||
</ConsentProvider>
|
||||
@@ -82,29 +96,18 @@ export default function ClientProviders({
|
||||
function GatedProviders({
|
||||
children,
|
||||
mounted,
|
||||
is404Page,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
mounted: boolean;
|
||||
is404Page: boolean;
|
||||
}) {
|
||||
const { consent } = useConsent();
|
||||
const pathname = usePathname();
|
||||
|
||||
const isAdminRoute = pathname.startsWith("/manage") || pathname.startsWith("/editor");
|
||||
|
||||
// If consent is not decided yet, treat optional features as off
|
||||
const analyticsEnabled = !!consent?.analytics;
|
||||
const chatEnabled = !!consent?.chat;
|
||||
|
||||
const content = (
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
{mounted && <BackgroundBlobs />}
|
||||
{mounted && <ShaderGradientBackground />}
|
||||
<div className="relative z-10">{children}</div>
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
||||
return locale.startsWith('de') ? 'de' : 'en';
|
||||
}
|
||||
|
||||
export function HeroClient({ locale, translations }: { locale: string; translations: HeroTranslations }) {
|
||||
export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) {
|
||||
const normalLocale = getNormalizedLocale(locale);
|
||||
const baseMessages = messageMap[normalLocale];
|
||||
|
||||
@@ -44,7 +44,7 @@ export function HeroClient({ locale, translations }: { locale: string; translati
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutClient({ locale, translations }: { locale: string; translations: AboutTranslations }) {
|
||||
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
|
||||
const normalLocale = getNormalizedLocale(locale);
|
||||
const baseMessages = messageMap[normalLocale];
|
||||
|
||||
@@ -61,7 +61,7 @@ export function AboutClient({ locale, translations }: { locale: string; translat
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsClient({ locale, translations }: { locale: string; translations: ProjectsTranslations }) {
|
||||
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
|
||||
const normalLocale = getNormalizedLocale(locale);
|
||||
const baseMessages = messageMap[normalLocale];
|
||||
|
||||
@@ -78,7 +78,7 @@ export function ProjectsClient({ locale, translations }: { locale: string; trans
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactClient({ locale, translations }: { locale: string; translations: ContactTranslations }) {
|
||||
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
|
||||
const normalLocale = getNormalizedLocale(locale);
|
||||
const baseMessages = messageMap[normalLocale];
|
||||
|
||||
@@ -95,7 +95,7 @@ export function ContactClient({ locale, translations }: { locale: string; transl
|
||||
);
|
||||
}
|
||||
|
||||
export function FooterClient({ locale, translations }: { locale: string; translations: FooterTranslations }) {
|
||||
export function FooterClient({ locale }: { locale: string; translations: FooterTranslations }) {
|
||||
const normalLocale = getNormalizedLocale(locale);
|
||||
const baseMessages = messageMap[normalLocale];
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ConsentBanner() {
|
||||
const { consent, ready, setConsent } = useConsent();
|
||||
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
||||
const [draft, setDraft] = useState<ConsentState>({ chat: false });
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const t = useTranslations("consent");
|
||||
|
||||
@@ -19,7 +19,6 @@ export default function ConsentBanner() {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
essential: t("essential"),
|
||||
analytics: t("analytics"),
|
||||
chat: t("chat"),
|
||||
alwaysOn: t("alwaysOn"),
|
||||
acceptAll: t("acceptAll"),
|
||||
@@ -68,16 +67,6 @@ export default function ConsentBanner() {
|
||||
<div className="text-[11px] text-stone-500">{s.alwaysOn}</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="text-sm font-semibold text-stone-800">{s.analytics}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.analytics}
|
||||
onChange={(e) => setDraft((p) => ({ ...p, analytics: e.target.checked }))}
|
||||
className="w-4 h-4 accent-stone-900"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="text-sm font-semibold text-stone-800">{s.chat}</span>
|
||||
<input
|
||||
@@ -91,7 +80,7 @@ export default function ConsentBanner() {
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setConsent({ analytics: true, chat: true })}
|
||||
onClick={() => setConsent({ chat: true })}
|
||||
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
{s.acceptAll}
|
||||
@@ -103,7 +92,7 @@ export default function ConsentBanner() {
|
||||
{s.acceptSelected}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConsent({ analytics: false, chat: false })}
|
||||
onClick={() => setConsent({ chat: false })}
|
||||
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
|
||||
>
|
||||
{s.rejectAll}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
|
||||
export type ConsentState = {
|
||||
analytics: boolean;
|
||||
chat: boolean;
|
||||
};
|
||||
|
||||
@@ -20,7 +19,6 @@ function readConsentFromCookie(): ConsentState | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<ConsentState>;
|
||||
return {
|
||||
analytics: !!parsed.analytics,
|
||||
chat: !!parsed.chat,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Mail, MapPin, Send } from "lucide-react";
|
||||
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();
|
||||
@@ -152,118 +153,120 @@ const Contact = () => {
|
||||
validateForm();
|
||||
};
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
title: tInfo("email"),
|
||||
value: "contact@dk0.dev",
|
||||
href: "mailto:contact@dk0.dev",
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
title: tInfo("location"),
|
||||
value: tInfo("locationValue"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
id="contact"
|
||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-teal/15 via-liquid-mint/10 to-liquid-lime/15"
|
||||
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Section Header */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||
|
||||
{/* Header Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-center mb-16"
|
||||
viewport={{ once: true }}
|
||||
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
|
||||
{t("title")}
|
||||
<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-emerald-600 dark:text-emerald-400">.</span>
|
||||
</h2>
|
||||
{cmsDoc ? (
|
||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
|
||||
<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" />
|
||||
) : (
|
||||
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Information */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-stone-900 mb-6">
|
||||
{t("getInTouch")}
|
||||
</h3>
|
||||
<p className="text-stone-700 leading-relaxed">
|
||||
{t("getInTouchBody")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<div className="space-y-4">
|
||||
{contactInfo.map((info, index) => (
|
||||
<motion.a
|
||||
key={info.title}
|
||||
href={info.href}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.1,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
}}
|
||||
whileHover={{
|
||||
x: 8,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-[background-color,border-color,box-shadow] duration-500 ease-out group border-transparent hover:border-white/70"
|
||||
>
|
||||
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
|
||||
<info.icon className="w-6 h-6 text-stone-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-stone-800">
|
||||
{info.title}
|
||||
</h4>
|
||||
<p className="text-stone-500">{info.value}</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Form */}
|
||||
{/* Info Side (Unified Connect Box) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
|
||||
>
|
||||
<h3 className="text-2xl font-bold text-gray-800 mb-6">
|
||||
<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">
|
||||
<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-700 dark:text-emerald-400">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
||||
{/* 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-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">
|
||||
<Mail size={16} />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
||||
|
||||
{/* 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-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">
|
||||
<Github size={16} />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
||||
|
||||
{/* 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-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">
|
||||
<Linkedin size={16} />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</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-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Form Side */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
|
||||
{tForm("title")}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Name <span className="text-liquid-rose">*</span>
|
||||
<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-600 dark:text-stone-400 pl-4">
|
||||
{tForm("labels.name")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -273,32 +276,14 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
||||
errors.name && touched.name
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||
placeholder={tForm("placeholders.name")}
|
||||
aria-invalid={
|
||||
errors.name && touched.name ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.name && touched.name ? "name-error" : undefined
|
||||
}
|
||||
/>
|
||||
{errors.name && touched.name && (
|
||||
<p id="name-error" className="mt-1 text-sm text-red-500">
|
||||
{errors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Email <span className="text-liquid-rose">*</span>
|
||||
<div className="space-y-2">
|
||||
<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
|
||||
type="email"
|
||||
@@ -308,33 +293,15 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
||||
errors.email && touched.email
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||
placeholder={tForm("placeholders.email")}
|
||||
aria-invalid={
|
||||
errors.email && touched.email ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.email && touched.email ? "email-error" : undefined
|
||||
}
|
||||
/>
|
||||
{errors.email && touched.email && (
|
||||
<p id="email-error" className="mt-1 text-sm text-red-500">
|
||||
{errors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Subject <span className="text-liquid-rose">*</span>
|
||||
<div className="space-y-2">
|
||||
<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
|
||||
type="text"
|
||||
@@ -344,34 +311,14 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
||||
errors.subject && touched.subject
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||
placeholder={tForm("placeholders.subject")}
|
||||
aria-invalid={
|
||||
errors.subject && touched.subject ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.subject && touched.subject
|
||||
? "subject-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{errors.subject && touched.subject && (
|
||||
<p id="subject-error" className="mt-1 text-sm text-red-500">
|
||||
{errors.subject}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Message <span className="text-liquid-rose">*</span>
|
||||
<div className="space-y-2">
|
||||
<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
|
||||
id="message"
|
||||
@@ -380,53 +327,25 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all resize-none ${
|
||||
errors.message && touched.message
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
rows={5}
|
||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
|
||||
placeholder={tForm("placeholders.message")}
|
||||
aria-invalid={
|
||||
errors.message && touched.message ? "true" : "false"
|
||||
}
|
||||
aria-describedby={
|
||||
errors.message && touched.message
|
||||
? "message-error"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
{errors.message && touched.message ? (
|
||||
<p id="message-error" className="text-sm text-red-500">
|
||||
{errors.message}
|
||||
</p>
|
||||
) : (
|
||||
<span></span>
|
||||
)}
|
||||
<span className="text-xs text-stone-400">
|
||||
{tForm("characters", { count: formData.message.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
|
||||
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-stone-950 transition-all duration-500 ease-out shadow-lg hover:shadow-xl"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>{tForm("sending")}</span>
|
||||
</>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send size={20} />
|
||||
<span className="text-cream">{tForm("send")}</span>
|
||||
<Send size={16} />
|
||||
{tForm("send")}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Github, Linkedin, Mail, ArrowUp } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
const Footer = () => {
|
||||
const locale = useLocale();
|
||||
@@ -16,17 +15,10 @@ const Footer = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-12 px-6 overflow-hidden transition-colors duration-500">
|
||||
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
{/* Huge Outlined Name */}
|
||||
<div className="relative mb-24 select-none pointer-events-none">
|
||||
<h2 className="text-[12vw] font-black leading-none text-stone-900/5 dark:text-white/5 uppercase tracking-tighter text-center">
|
||||
Dennis Konkol
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 items-end">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
|
||||
{/* Copyright & Info */}
|
||||
<div className="md:col-span-4 space-y-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
||||
@@ -41,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>
|
||||
@@ -60,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>
|
||||
@@ -71,13 +63,13 @@ const Footer = () => {
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-20 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">
|
||||
<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-600 dark:text-stone-400 uppercase tracking-widest">
|
||||
Built with Next.js, Directus & Passion.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowDown, Github, Linkedin, Mail, Code, Zap } from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -42,87 +41,80 @@ const Hero = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 max-w-7xl mx-auto w-full pt-20">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-24">
|
||||
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24">
|
||||
|
||||
{/* Left: Text Content */}
|
||||
<div className="flex-1 text-center lg:text-left space-y-10">
|
||||
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-3 px-5 py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
|
||||
className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
|
||||
>
|
||||
<span className="w-2.5 h-2.5 bg-green-500 rounded-full animate-pulse" />
|
||||
<span className="font-mono text-[10px] font-black uppercase tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
|
||||
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-6xl md:text-[9.5rem] font-black tracking-tighter leading-[0.8] text-stone-900 dark:text-stone-50 uppercase">
|
||||
<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-liquid-mint via-liquid-sky to-liquid-purple pb-4"
|
||||
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"
|
||||
>
|
||||
{getLabel("hero.line2", "Stuff.")}
|
||||
</motion.span>
|
||||
</h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1, delay: 0.4 }}
|
||||
className="text-xl md: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 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
className="flex flex-col sm:flex-row items-center gap-8 justify-center lg:justify-start pt-4"
|
||||
className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4"
|
||||
>
|
||||
<a href="#projects" className="group relative px-12 py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
||||
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
||||
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||
{t("ctaWork")}
|
||||
</a>
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
{ icon: Github, href: "https://github.com/Denshooter" },
|
||||
{ icon: Linkedin, href: "https://linkedin.com/in/dkonkol" },
|
||||
{ icon: Mail, href: "mailto:contact@dk0.dev" }
|
||||
].map((social, i) => (
|
||||
<a key={i} href={social.href} target="_blank" rel="noopener noreferrer" className="p-5 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-[1.5rem] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-all shadow-sm hover:shadow-xl">
|
||||
<social.icon size={22} />
|
||||
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||
{t("ctaContact")}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right: The Photo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8, rotate: 5 }}
|
||||
animate={{ opacity: 1, scale: 1, rotate: 0 }}
|
||||
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="relative w-72 h-72 md:w-[500px] md:h-[500px] shrink-0 mt-12 lg:mt-0"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1
|
||||
}}
|
||||
transition={{
|
||||
opacity: { duration: 1 },
|
||||
scale: { duration: 1 }
|
||||
}}
|
||||
className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl animate-pulse" />
|
||||
<div className="relative w-full h-full rounded-[4rem] overflow-hidden border-[16px] border-white dark:border-stone-900 shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
||||
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority />
|
||||
<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 sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1024px) 320px, 500px" />
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-6 -left-6 bg-white dark:bg-stone-800 px-8 py-4 rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
||||
<span className="font-mono text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
||||
<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">
|
||||
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const Projects = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("home.projects");
|
||||
useTranslations("home.projects");
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
@@ -47,14 +47,14 @@ const Projects = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="projects" className="py-32 px-4 bg-stone-50 dark:bg-stone-950">
|
||||
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
|
||||
<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-4xl md:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-4 uppercase">
|
||||
Selected Work<span className="text-liquid-mint">.</span>
|
||||
<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-emerald-600 dark:text-emerald-400">.</span>
|
||||
</h2>
|
||||
<p className="text-xl text-stone-500 max-w-xl font-light">
|
||||
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
||||
Projects that pushed my boundaries.
|
||||
</p>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ const Projects = () => {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
||||
{loading ? (
|
||||
Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="space-y-6">
|
||||
@@ -85,7 +85,7 @@ const Projects = () => {
|
||||
>
|
||||
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
||||
{/* Image Card */}
|
||||
<div className="relative aspect-[4/3] rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-6">
|
||||
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
|
||||
{project.imageUrl ? (
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
@@ -105,14 +105,14 @@ const Projects = () => {
|
||||
{/* Text Content */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-2 group-hover:underline decoration-2 underline-offset-4">
|
||||
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
|
||||
{project.title}
|
||||
</h3>
|
||||
<p className="text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
|
||||
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden md:flex gap-2">
|
||||
<div className="hidden sm:flex gap-2">
|
||||
{project.tags.slice(0, 2).map(tag => (
|
||||
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
|
||||
{tag}
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// Lazy load providers to avoid webpack module resolution issues
|
||||
const AnalyticsProvider = React.lazy(() =>
|
||||
import("@/components/AnalyticsProvider").then((mod) => ({
|
||||
default: mod.AnalyticsProvider,
|
||||
}))
|
||||
);
|
||||
|
||||
const ToastProvider = React.lazy(() =>
|
||||
import("@/components/Toast").then((mod) => ({
|
||||
default: mod.ToastProvider,
|
||||
@@ -38,14 +31,11 @@ export default function RootProviders({
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
|
||||
<AnalyticsProvider>
|
||||
<ToastProvider>
|
||||
<BackgroundBlobs />
|
||||
<div className="relative z-10">{children}</div>
|
||||
<ChatWidget />
|
||||
</ToastProvider>
|
||||
</AnalyticsProvider>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const ShaderGradientBackground = () => {
|
||||
<ShaderGradient
|
||||
control="props"
|
||||
type="sphere"
|
||||
animate="on"
|
||||
animate="off"
|
||||
brightness={1.3}
|
||||
cAzimuthAngle={180}
|
||||
cDistance={3.6}
|
||||
@@ -57,7 +57,7 @@ const ShaderGradientBackground = () => {
|
||||
<ShaderGradient
|
||||
control="props"
|
||||
type="sphere"
|
||||
animate="on"
|
||||
animate="off"
|
||||
brightness={1.25}
|
||||
cAzimuthAngle={180}
|
||||
cDistance={3.6}
|
||||
@@ -83,7 +83,7 @@ const ShaderGradientBackground = () => {
|
||||
<ShaderGradient
|
||||
control="props"
|
||||
type="sphere"
|
||||
animate="on"
|
||||
animate="off"
|
||||
brightness={1.2}
|
||||
cAzimuthAngle={180}
|
||||
cDistance={3.6}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
:root {
|
||||
/* Warm Brown & Off-White Palette */
|
||||
--background: #faf8f3; /* Warm off-white */
|
||||
|
||||
@@ -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",
|
||||
@@ -34,7 +33,7 @@ export default async function RootLayout({
|
||||
<meta charSet="utf-8" />
|
||||
</head>
|
||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||
<ShaderGradientBackground />
|
||||
<div className="grain-overlay" aria-hidden="true" />
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Home, ArrowLeft, Search, Ghost, RefreshCcw } from "lucide-react";
|
||||
import { ArrowLeft, Search, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -17,106 +17,93 @@ export default function NotFound() {
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8] dark:bg-stone-950 overflow-hidden relative">
|
||||
{/* Liquid Background Blobs */}
|
||||
<motion.div
|
||||
className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-liquid-mint/20 dark:bg-liquid-mint/10 rounded-full blur-[120px]"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
x: [0, 50, 0],
|
||||
y: [0, 30, 0],
|
||||
}}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-liquid-rose/20 dark:bg-liquid-rose/10 rounded-full blur-[120px]"
|
||||
animate={{
|
||||
scale: [1.2, 1, 1.2],
|
||||
x: [0, -50, 0],
|
||||
y: [0, -30, 0],
|
||||
}}
|
||||
transition={{ duration: 18, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-16 sm:py-20 md:py-24 px-4 sm:px-6 flex items-center justify-center transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8 max-w-5xl mx-auto">
|
||||
|
||||
<div className="relative z-10 max-w-2xl w-full px-6 text-center">
|
||||
{/* Large 404 with Liquid Animation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="relative inline-block mb-8"
|
||||
>
|
||||
<h1 className="text-[12rem] md:text-[16rem] font-black text-stone-900/5 dark:text-stone-100/5 select-none leading-none">
|
||||
404
|
||||
</h1>
|
||||
<motion.div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<Ghost size={120} className="text-stone-800 dark:text-stone-200 opacity-80" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Content Card */}
|
||||
{/* Main Error Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="bg-white/40 dark:bg-stone-900/40 backdrop-blur-2xl border border-white/60 dark:border-white/10 rounded-[2.5rem] p-8 md:p-12 shadow-[0_20px_50px_rgba(0,0,0,0.05)] dark:shadow-none"
|
||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[300px] sm:min-h-[400px]"
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-stone-900 dark:text-stone-50 mb-4 font-sans tracking-tight">
|
||||
Lost in the Liquid.
|
||||
</h2>
|
||||
<p className="text-stone-600 dark:text-stone-400 text-lg md:text-xl font-light leading-relaxed mb-10 max-w-md mx-auto">
|
||||
The page you are looking for has evaporated or never existed in this dimension.
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6 sm:mb-8 md:mb-12">
|
||||
<div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
||||
404
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.85] mb-4 sm:mb-6 md:mb-8">
|
||||
Page not <br/>Found<span className="text-liquid-mint">.</span>
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
|
||||
The content you are looking for has been moved, deleted, or never existed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center justify-center gap-3 px-8 py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-bold hover:scale-[1.02] active:scale-[0.98] transition-all shadow-lg hover:shadow-xl dark:shadow-none group"
|
||||
className="group relative px-6 sm:px-10 py-3 sm:py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
|
||||
>
|
||||
<Home size={20} className="group-hover:-translate-y-0.5 transition-transform" />
|
||||
<span>Back Home</span>
|
||||
Return Home
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center justify-center gap-3 px-8 py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-2xl font-bold hover:bg-stone-50 dark:hover:bg-stone-700 hover:scale-[1.02] active:scale-[0.98] transition-all shadow-sm group"
|
||||
className="px-6 sm:px-10 py-3 sm:py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Go Back</span>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-stone-100 dark:border-stone-800">
|
||||
{/* Sidebar Cards */}
|
||||
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6">
|
||||
{/* Search/Explore Projects */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
|
||||
>
|
||||
<div className="relative z-10">
|
||||
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
|
||||
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
|
||||
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-2 text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-100 transition-colors font-medium group"
|
||||
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
|
||||
>
|
||||
<Search size={18} className="group-hover:rotate-12 transition-transform" />
|
||||
<span>Looking for my work? Explore projects</span>
|
||||
View Projects <ArrowLeft className="rotate-180" size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
|
||||
</motion.div>
|
||||
|
||||
{/* Floating Help Badge */}
|
||||
{/* Visit the Lab */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
className="mt-12"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] 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 group"
|
||||
>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-stone-100 dark:bg-stone-800 rounded-full text-stone-500 dark:text-stone-400 text-xs font-mono hover:text-stone-800 dark:hover:text-stone-200 transition-colors"
|
||||
<div className="relative z-10">
|
||||
<Terminal className="text-liquid-purple mb-4 sm:mb-6" size={28} />
|
||||
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
|
||||
<p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="mt-8 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"
|
||||
>
|
||||
<RefreshCcw size={12} />
|
||||
<span>ERR_PAGE_NOT_FOUND_404</span>
|
||||
</button>
|
||||
Enter the Lab <ArrowLeft className="rotate-180" size={14} />
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,25 +41,6 @@ const ProjectDetail = () => {
|
||||
const loadedProject = data.projects[0];
|
||||
setProject(loadedProject);
|
||||
|
||||
// Track page view
|
||||
try {
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'pageview',
|
||||
projectId: loadedProject.id.toString(),
|
||||
page: `/projects/${slug}`
|
||||
})
|
||||
});
|
||||
} catch (trackError) {
|
||||
// Silently fail tracking
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error tracking page view:', trackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useWebVitals } from '@/lib/useWebVitals';
|
||||
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
||||
import { debounce } from '@/lib/utils';
|
||||
|
||||
interface AnalyticsProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
||||
const hasTrackedInitialView = useRef(false);
|
||||
const hasTrackedPerformance = useRef(false);
|
||||
const currentPath = useRef('');
|
||||
|
||||
// Initialize Web Vitals tracking - wrapped to prevent crashes
|
||||
// Hooks must be called unconditionally, but the hook itself handles errors
|
||||
useWebVitals();
|
||||
|
||||
// Track page view - memoized to prevent recreation
|
||||
const trackPageView = useCallback(async () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Only track if path has changed (prevents duplicate tracking)
|
||||
if (currentPath.current === path && hasTrackedInitialView.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentPath.current = path;
|
||||
hasTrackedInitialView.current = true;
|
||||
|
||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
||||
const projectId = projectMatch ? projectMatch[1] : null;
|
||||
|
||||
// Track to Umami (if available)
|
||||
trackEvent('page-view', {
|
||||
url: path,
|
||||
referrer: document.referrer,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Track to our API - single call
|
||||
try {
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'pageview',
|
||||
projectId: projectId,
|
||||
page: path
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error tracking page view:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Wrap entire effect in try-catch to prevent any errors from breaking the app
|
||||
try {
|
||||
// Track page load performance - wrapped in try-catch
|
||||
try {
|
||||
trackPageLoad();
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking page load:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Track initial page view
|
||||
trackPageView();
|
||||
|
||||
// Track performance metrics to our API - only once
|
||||
const trackPerformanceToAPI = async () => {
|
||||
// Prevent duplicate tracking
|
||||
if (hasTrackedPerformance.current) return;
|
||||
hasTrackedPerformance.current = true;
|
||||
|
||||
try {
|
||||
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current page path to extract project ID if on project page
|
||||
const path = window.location.pathname;
|
||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
||||
const projectId = projectMatch ? projectMatch[1] : null;
|
||||
|
||||
// Wait for page to fully load
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
|
||||
|
||||
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
|
||||
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
|
||||
|
||||
const performanceData = {
|
||||
loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
|
||||
fcp: fcp ? fcp.startTime : 0,
|
||||
lcp: lcp ? lcp.startTime : 0,
|
||||
ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
|
||||
cls: 0, // Will be updated by CLS observer
|
||||
fid: 0, // Will be updated by FID observer
|
||||
si: 0 // Speed Index - would need to calculate
|
||||
};
|
||||
|
||||
// Send performance data - single call
|
||||
await fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'performance',
|
||||
projectId: projectId,
|
||||
page: path,
|
||||
performance: performanceData
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - performance tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error collecting performance data:', error);
|
||||
}
|
||||
}
|
||||
}, 2500); // Wait 2.5 seconds for page to stabilize
|
||||
} catch (error) {
|
||||
// Silently fail
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error tracking performance:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track performance after page load
|
||||
if (document.readyState === 'complete') {
|
||||
trackPerformanceToAPI();
|
||||
} else {
|
||||
window.addEventListener('load', trackPerformanceToAPI, { once: true });
|
||||
}
|
||||
|
||||
// Track route changes (for SPA navigation) - debounced
|
||||
const handleRouteChange = debounce(() => {
|
||||
// Track new page view (trackPageView will handle path change detection)
|
||||
trackPageView();
|
||||
trackPageLoad();
|
||||
}, 300);
|
||||
|
||||
// Listen for popstate events (back/forward navigation)
|
||||
window.addEventListener('popstate', handleRouteChange);
|
||||
|
||||
// Track user interactions - debounced to prevent spam
|
||||
const handleClick = debounce((event: unknown) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mouseEvent = event as MouseEvent;
|
||||
const target = mouseEvent.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
|
||||
const className = target.className;
|
||||
const id = target.id;
|
||||
|
||||
trackEvent('click', {
|
||||
element,
|
||||
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined,
|
||||
id: id || undefined,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - click tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking click:', error);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Track form submissions
|
||||
const handleSubmit = (event: SubmitEvent) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const form = event.target as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
|
||||
trackEvent('form-submit', {
|
||||
formId: form.id || undefined,
|
||||
formClass: form.className || undefined,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - form tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking form submit:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track scroll depth - debounced
|
||||
let maxScrollDepth = 0;
|
||||
const firedScrollMilestones = new Set<number>();
|
||||
const handleScroll = debounce(() => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
||||
|
||||
const scrollHeight = document.documentElement.scrollHeight;
|
||||
const innerHeight = window.innerHeight;
|
||||
|
||||
if (scrollHeight <= innerHeight) return; // No scrollable content
|
||||
|
||||
const scrollDepth = Math.round(
|
||||
(window.scrollY / (scrollHeight - innerHeight)) * 100
|
||||
);
|
||||
|
||||
if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
|
||||
|
||||
// Track each milestone once (avoid spamming events on every scroll tick)
|
||||
const milestones = [25, 50, 75, 90];
|
||||
for (const milestone of milestones) {
|
||||
if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
|
||||
firedScrollMilestones.add(milestone);
|
||||
trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - scroll tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking scroll:', error);
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Add event listeners
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('submit', handleSubmit);
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// Track errors
|
||||
const handleError = (event: ErrorEvent) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('error', {
|
||||
message: event.message || 'Unknown error',
|
||||
filename: event.filename || undefined,
|
||||
lineno: event.lineno || undefined,
|
||||
colno: event.colno || undefined,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - error tracking should not cause more errors
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking error event:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('unhandled-rejection', {
|
||||
reason: event.reason?.toString() || 'Unknown rejection',
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - error tracking should not cause more errors
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking unhandled rejection:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('error', handleError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
try {
|
||||
// Cancel any pending debounced calls to prevent memory leaks
|
||||
handleRouteChange.cancel();
|
||||
handleClick.cancel();
|
||||
handleScroll.cancel();
|
||||
|
||||
// Remove event listeners
|
||||
window.removeEventListener('load', trackPerformanceToAPI);
|
||||
window.removeEventListener('popstate', handleRouteChange);
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('submit', handleSubmit);
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('error', handleError);
|
||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
} catch {
|
||||
// Silently fail during cleanup
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// If anything fails, log but don't break the app
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('AnalyticsProvider initialization error:', error);
|
||||
}
|
||||
// Return empty cleanup function
|
||||
return () => {};
|
||||
}
|
||||
}, [trackPageView]);
|
||||
|
||||
// Always render children, even if analytics fails
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,169 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useTransform, useSpring } from "framer-motion";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const BackgroundBlobs = () => {
|
||||
const mouseX = useMotionValue(0);
|
||||
const mouseY = useMotionValue(0);
|
||||
|
||||
const springConfig = { damping: 50, stiffness: 50, mass: 2 };
|
||||
const springX = useSpring(mouseX, springConfig);
|
||||
const springY = useSpring(mouseY, springConfig);
|
||||
|
||||
// Very subtle parallax offsets
|
||||
const x1 = useTransform(springX, (value) => value / 30);
|
||||
const y1 = useTransform(springY, (value) => value / 30);
|
||||
|
||||
const x2 = useTransform(springX, (value) => value / -25);
|
||||
const y2 = useTransform(springY, (value) => value / -25);
|
||||
|
||||
const x3 = useTransform(springX, (value) => value / 20);
|
||||
const y3 = useTransform(springY, (value) => value / 20);
|
||||
|
||||
const x4 = useTransform(springX, (value) => value / -35);
|
||||
const y4 = useTransform(springY, (value) => value / -35);
|
||||
|
||||
const x5 = useTransform(springX, (value) => value / 15);
|
||||
const y5 = useTransform(springY, (value) => value / 15);
|
||||
|
||||
// Prevent hydration mismatch
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const x = e.clientX - window.innerWidth / 2;
|
||||
const y = e.clientY - window.innerHeight / 2;
|
||||
mouseX.set(x);
|
||||
mouseY.set(y);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [mouseX, mouseY, mounted]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
||||
{/* Mint blob - top left */}
|
||||
<motion.div
|
||||
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[100px] mix-blend-multiply"
|
||||
style={{ x: x1, y: y1 }}
|
||||
animate={{
|
||||
scale: [1, 1.15, 1],
|
||||
rotate: [0, 45, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 40,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.15, 1] }}
|
||||
transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
|
||||
{/* Lavender blob - top right */}
|
||||
<motion.div
|
||||
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[100px] mix-blend-multiply"
|
||||
style={{ x: x2, y: y2 }}
|
||||
animate={{
|
||||
scale: [1, 1.1, 1],
|
||||
rotate: [0, -30, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 45,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
|
||||
{/* Rose blob - bottom left */}
|
||||
<motion.div
|
||||
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[100px] mix-blend-multiply"
|
||||
style={{ x: x3, y: y3 }}
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
rotate: [0, 60, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 50,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
|
||||
{/* Peach blob - middle right */}
|
||||
<motion.div
|
||||
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[120px] mix-blend-multiply"
|
||||
style={{ x: x4, y: y4 }}
|
||||
animate={{
|
||||
scale: [1, 1.25, 1],
|
||||
rotate: [0, -45, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 55,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Blue blob - center */}
|
||||
<motion.div
|
||||
className="absolute top-[50%] left-[40%] w-[38vw] h-[38vw] bg-blue-200/30 rounded-full blur-[110px] mix-blend-multiply"
|
||||
style={{ x: x5, y: y5 }}
|
||||
animate={{
|
||||
scale: [1, 1.18, 1],
|
||||
rotate: [0, 90, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 48,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pink blob - bottom right */}
|
||||
<motion.div
|
||||
className="absolute bottom-[10%] right-[-8%] w-[32vw] h-[32vw] bg-pink-200/35 rounded-full blur-[100px] mix-blend-multiply"
|
||||
animate={{
|
||||
scale: [1, 1.12, 1],
|
||||
rotate: [0, -60, 0],
|
||||
x: [0, -20, 0],
|
||||
y: [0, 20, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 43,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Yellow-green blob - top center */}
|
||||
<motion.div
|
||||
className="absolute top-[5%] left-[45%] w-[28vw] h-[28vw] bg-lime-200/30 rounded-full blur-[115px] mix-blend-multiply"
|
||||
animate={{
|
||||
scale: [1, 1.22, 1],
|
||||
rotate: [0, 75, 0],
|
||||
x: [0, 15, 0],
|
||||
y: [0, -15, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 52,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
|
||||
animate={{ scale: [1, 1.25, 1] }}
|
||||
transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,6 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: portfolio_postgres_dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: portfolio_dev
|
||||
POSTGRES_USER: portfolio_user
|
||||
@@ -26,8 +24,6 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: portfolio_redis_dev
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_dev_data:/data
|
||||
networks:
|
||||
@@ -40,7 +36,7 @@ services:
|
||||
|
||||
networks:
|
||||
portfolio_dev:
|
||||
driver: bridge
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
postgres_dev_data:
|
||||
|
||||
@@ -113,6 +113,6 @@ volumes:
|
||||
|
||||
networks:
|
||||
portfolio_net:
|
||||
driver: bridge
|
||||
external: true
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
33
docs/DESIGN_OVERHAUL_LOG.md
Normal file
33
docs/DESIGN_OVERHAUL_LOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Design Overhaul Log: Editorial Bento Transformation (Feb 2026)
|
||||
|
||||
## Zielsetzung
|
||||
Transformation des Portfolios von einem Standard-Layout in ein hochkarätiges "Editorial Bento Grid" Design. Fokus auf Typografie, Dynamik (Directus CMS) und Premium UX ("Liquid" Aesthetic).
|
||||
|
||||
## Meilensteine & Kernänderungen
|
||||
|
||||
### 1. UI/UX Architektur
|
||||
- **Editorial Bento Grid:** Umstellung der gesamten Seite auf ein modulares Grid-System.
|
||||
- **Liquid Aesthetic:** Nutzung von Glassmorphismus, extremen Abrundungen (`rounded-[3rem]`) und weichen Verläufen (`liquid-mint`, `liquid-purple`).
|
||||
- **Typography-First:** Einsatz von riesigen, uppercase Headlines mit markanten Endpunkten (z.B. `Archive.`) für einen Magazin-Look.
|
||||
|
||||
### 2. Dynamische Features
|
||||
- **ActivityFeed 2.0:**
|
||||
- Integration von Echtzeit-Status (Spotify, Coding, Gaming) direkt in die Grid-Zellen.
|
||||
- **Quote Carousel:** Automatischer Wechsel zwischen nerdigen Zitaten (Dijkstra, Einstein etc.) alle 10s im Idle-Mode.
|
||||
- Fallback-Logik für CMS-gesteuerte Quotes (`about.quote.idle`).
|
||||
- **Skeleton Loading:** Implementierung von Shimmer-Skeletons für alle dynamischen Daten (Hero, Bento, Library, Projects), um Layout-Shift zu verhindern.
|
||||
|
||||
### 3. Navigation & Struktur
|
||||
- **Minimalist Dock:** Die Navbar wurde zu einer schwebenden, asymmetrischen "Pill" transformiert.
|
||||
- **Intelligent Back Button:** Die Projektdetailseiten erkennen nun den Ursprung des Nutzers (Home vs. Archiv) und führen kontextsensitiv zurück.
|
||||
- **Unified Sub-Pages:** Übertragung des Bento-Stils auf `/books`, `/projects`, `/legal-notice` und `/privacy-policy`.
|
||||
|
||||
### 4. Technische Stabilität
|
||||
- **TypeScript Hardening:** Alle `any`-Typen in den neuen Komponenten wurden durch strikte Interfaces (`ProjectDetailData`, `ProjectListItem`) ersetzt.
|
||||
- **Docker Ready:** Korrektur von Build-Errors (Klammerfehler, fehlende Imports), sodass das Image erfolgreich baut.
|
||||
- **Test Suite:** Update der Jest-Tests auf das neue Design und Polyfilling von `Request`/`Response` für API-Tests.
|
||||
|
||||
## Design Mandate für die Zukunft
|
||||
- Kontrastreiches Grün (`emerald-500`) für helle Hintergründe verwenden.
|
||||
- Animationen immer mit Framer Motion (Staggered Effects, Floating).
|
||||
- Keine Overlays; alle Widgets müssen Teil des Grids sein.
|
||||
@@ -16,6 +16,8 @@ const eslintConfig = [
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"scripts/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -31,15 +31,15 @@ Object.defineProperty(window, "IntersectionObserver", {
|
||||
|
||||
// Polyfill Headers/Request/Response
|
||||
if (!global.Headers) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - Polyfilling global Headers for jest environment
|
||||
global.Headers = Headers;
|
||||
}
|
||||
if (!global.Request) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - Polyfilling global Request for jest environment
|
||||
global.Request = Request;
|
||||
}
|
||||
if (!global.Response) {
|
||||
// @ts-ignore
|
||||
// @ts-expect-error - Polyfilling global Response for jest environment
|
||||
global.Response = Response;
|
||||
}
|
||||
|
||||
@@ -49,13 +49,16 @@ jest.mock('next/server', () => {
|
||||
return {
|
||||
...actual,
|
||||
NextResponse: {
|
||||
json: (data: any, init?: any) => {
|
||||
const res = new Response(JSON.stringify(data), init);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
json: (data: Record<string, unknown>, init?: any) => {
|
||||
// Use global Response from whatwg-fetch
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const res = new (global as any).Response(JSON.stringify(data), init);
|
||||
res.headers.set('Content-Type', 'application/json');
|
||||
return res;
|
||||
},
|
||||
next: () => ({ headers: new Headers() }),
|
||||
redirect: (url: string) => ({ headers: new Headers(), status: 302 }),
|
||||
redirect: (_url: string) => ({ headers: new Headers(), status: 302 }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
144
lib/analytics.ts
144
lib/analytics.ts
@@ -1,144 +0,0 @@
|
||||
// Analytics utilities for Umami with Performance Tracking
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: {
|
||||
track: (event: string, data?: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface PerformanceMetric {
|
||||
name: string;
|
||||
value: number;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export interface WebVitalsMetric {
|
||||
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
||||
value: number;
|
||||
delta: number;
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Track custom events to Umami
|
||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const trackFn = window.umami?.track;
|
||||
if (typeof trackFn !== "function") return;
|
||||
|
||||
try {
|
||||
trackFn(event, {
|
||||
...data,
|
||||
timestamp: Date.now(),
|
||||
url: window.location.pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - analytics must never break the app
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn("Error tracking Umami event:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track performance metrics
|
||||
export const trackPerformance = (metric: PerformanceMetric) => {
|
||||
trackEvent('performance', {
|
||||
metric: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
url: metric.url,
|
||||
userAgent: metric.userAgent,
|
||||
});
|
||||
};
|
||||
|
||||
// Track Web Vitals
|
||||
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
||||
trackEvent('web-vitals', {
|
||||
name: metric.name,
|
||||
value: Math.round(metric.value),
|
||||
delta: Math.round(metric.delta),
|
||||
id: metric.id,
|
||||
url: metric.url,
|
||||
});
|
||||
};
|
||||
|
||||
// Track page load performance
|
||||
export const trackPageLoad = () => {
|
||||
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
|
||||
|
||||
try {
|
||||
const navigationEntries = performance.getEntriesByType('navigation');
|
||||
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
|
||||
|
||||
if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
|
||||
trackPerformance({
|
||||
name: 'page-load',
|
||||
value: navigation.loadEventEnd - navigation.fetchStart,
|
||||
url: window.location.pathname,
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent,
|
||||
});
|
||||
|
||||
// Track individual timing phases
|
||||
trackEvent('page-timing', {
|
||||
dns: navigation.domainLookupEnd && navigation.domainLookupStart
|
||||
? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
|
||||
: 0,
|
||||
tcp: navigation.connectEnd && navigation.connectStart
|
||||
? Math.round(navigation.connectEnd - navigation.connectStart)
|
||||
: 0,
|
||||
request: navigation.responseStart && navigation.requestStart
|
||||
? Math.round(navigation.responseStart - navigation.requestStart)
|
||||
: 0,
|
||||
response: navigation.responseEnd && navigation.responseStart
|
||||
? Math.round(navigation.responseEnd - navigation.responseStart)
|
||||
: 0,
|
||||
dom: navigation.domContentLoadedEventEnd && navigation.responseEnd
|
||||
? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd)
|
||||
: 0,
|
||||
load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd
|
||||
? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd)
|
||||
: 0,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - performance tracking is not critical
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn('Error tracking page load:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track API response times
|
||||
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('api-call', {
|
||||
endpoint,
|
||||
duration: Math.round(duration),
|
||||
status,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
// Track user interactions
|
||||
export const trackInteraction = (action: string, element?: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('interaction', {
|
||||
action,
|
||||
element,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
// Track errors
|
||||
export const trackError = (error: string, context?: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
trackEvent('error', {
|
||||
error,
|
||||
context,
|
||||
url: window.location.pathname,
|
||||
});
|
||||
};
|
||||
@@ -196,9 +196,9 @@ if (typeof window === 'undefined') {
|
||||
}, 60000); // Clear every minute
|
||||
}
|
||||
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean {
|
||||
const now = Date.now();
|
||||
const key = `admin_${ip}`;
|
||||
const key = `${prefix}_${ip}`;
|
||||
|
||||
const current = rateLimitMap.get(key);
|
||||
|
||||
@@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> {
|
||||
const current = rateLimitMap.get(`admin_${ip}`);
|
||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record<string, string> {
|
||||
const current = rateLimitMap.get(`${prefix}_${ip}`);
|
||||
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
||||
|
||||
return {
|
||||
|
||||
285
lib/directus.ts
285
lib/directus.ts
@@ -24,7 +24,11 @@ function toDirectusLocale(locale: string): string {
|
||||
|
||||
interface FetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: any;
|
||||
body?: {
|
||||
query?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
async function directusRequest<T>(
|
||||
@@ -75,9 +79,9 @@ async function directusRequest<T>(
|
||||
}
|
||||
|
||||
return data?.data || null;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Timeout oder Network Error - stille fallback
|
||||
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
||||
if (error && typeof error === 'object' && 'name' in error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Directus timeout');
|
||||
}
|
||||
@@ -85,7 +89,8 @@ async function directusRequest<T>(
|
||||
}
|
||||
// Andere Errors nur in dev loggen
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Directus request failed:', error?.message);
|
||||
const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : 'Unknown error';
|
||||
console.error('Directus request failed:', message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -107,16 +112,25 @@ export async function getMessages(locale: string): Promise<Record<string, string
|
||||
|
||||
try {
|
||||
const result = await directusRequest('', { body: { query } });
|
||||
const messages = (result as any)?.messages || [];
|
||||
interface MessageData {
|
||||
messages: Array<{
|
||||
key: string;
|
||||
translations?: Array<{
|
||||
languages_code?: { code: string };
|
||||
value?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const messages = (result as MessageData | null)?.messages || [];
|
||||
const dictionary: Record<string, string> = {};
|
||||
|
||||
messages.forEach((m: any) => {
|
||||
const trans = m.translations?.find((t: any) => t.languages_code?.code === directusLocale);
|
||||
messages.forEach((m) => {
|
||||
const trans = m.translations?.find((t) => t.languages_code?.code === directusLocale);
|
||||
if (trans?.value) dictionary[m.key] = trans.value;
|
||||
});
|
||||
|
||||
return dictionary;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -140,21 +154,35 @@ export async function getMessage(key: string, locale: string): Promise<string |
|
||||
|
||||
try {
|
||||
const result = await directusRequest('', { body: { query } });
|
||||
const messages = (result as any)?.messages;
|
||||
interface SingleMessageData {
|
||||
messages: Array<{
|
||||
translations?: Array<{
|
||||
languages_code?: { code: string };
|
||||
value?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const messages = (result as SingleMessageData | null)?.messages;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
const translations = messages[0]?.translations || [];
|
||||
const translation = translations.find((t: any) => t.languages_code?.code === directusLocale);
|
||||
const translation = translations.find((t) => t.languages_code?.code === directusLocale);
|
||||
return translation?.value || null;
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContentPage {
|
||||
slug: string;
|
||||
content?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function getContentPage(
|
||||
slug: string,
|
||||
locale: string
|
||||
): Promise<any | null> {
|
||||
): Promise<ContentPage | null> {
|
||||
const directusLocale = toDirectusLocale(locale);
|
||||
const query = `
|
||||
query {
|
||||
@@ -182,7 +210,10 @@ export async function getContentPage(
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const pages = (result as any)?.content_pages || [];
|
||||
interface ContentPagesResult {
|
||||
content_pages: ContentPage[];
|
||||
}
|
||||
const pages = (result as ContentPagesResult | null)?.content_pages || [];
|
||||
if (pages.length === 0) {
|
||||
// Try without locale filter
|
||||
const fallbackQuery = `
|
||||
@@ -200,7 +231,7 @@ export async function getContentPage(
|
||||
}
|
||||
`;
|
||||
const fallbackResult = await directusRequest('', { body: { query: fallbackQuery } });
|
||||
const fallbackPages = (fallbackResult as any)?.content_pages || [];
|
||||
const fallbackPages = (fallbackResult as ContentPagesResult | null)?.content_pages || [];
|
||||
return fallbackPages[0] || null;
|
||||
}
|
||||
|
||||
@@ -248,13 +279,6 @@ const fallbackTechStackData: Record<string, Array<{ key: string; items: string[]
|
||||
]
|
||||
};
|
||||
|
||||
const categoryIconMap: Record<string, string> = {
|
||||
frontend: 'Globe',
|
||||
backend: 'Server',
|
||||
tools: 'Wrench',
|
||||
security: 'Shield'
|
||||
};
|
||||
|
||||
const categoryNames: Record<string, Record<string, string>> = {
|
||||
'en-US': {
|
||||
frontend: 'Frontend & Mobile',
|
||||
@@ -301,7 +325,19 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
{ body: { query: categoriesQuery } }
|
||||
);
|
||||
|
||||
const categories = (categoriesResult as any)?.tech_stack_categories;
|
||||
interface TechStackCategoriesResult {
|
||||
tech_stack_categories: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
icon: string;
|
||||
sort: number;
|
||||
translations?: Array<{
|
||||
languages_code?: { code: string };
|
||||
name?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const categories = (categoriesResult as TechStackCategoriesResult | null)?.tech_stack_categories;
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
@@ -325,15 +361,25 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
);
|
||||
|
||||
const itemsData = await itemsResponse.json();
|
||||
const allItems = itemsData?.data || [];
|
||||
interface ItemsResponseData {
|
||||
data: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string | number;
|
||||
url?: string;
|
||||
icon_url?: string;
|
||||
sort: number;
|
||||
}>;
|
||||
}
|
||||
const allItems = (itemsData as ItemsResponseData | null)?.data || [];
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[getTechStack] Fetched items:', allItems.length);
|
||||
}
|
||||
|
||||
// Group items by category
|
||||
const categoriesWithItems = categories.map((cat: any) => {
|
||||
const categoryItems = allItems.filter((item: any) =>
|
||||
const categoriesWithItems = categories.map((cat) => {
|
||||
const categoryItems = allItems.filter((item) =>
|
||||
item.category === cat.id || item.category === parseInt(cat.id)
|
||||
);
|
||||
|
||||
@@ -346,6 +392,7 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
itemsToUse = categoryFallback.items.map((name, idx) => ({
|
||||
id: `fallback-${cat.key}-${idx}`,
|
||||
name: name,
|
||||
category: cat.id,
|
||||
url: undefined,
|
||||
icon_url: undefined,
|
||||
sort: idx + 1
|
||||
@@ -359,7 +406,7 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
icon: cat.icon,
|
||||
sort: cat.sort,
|
||||
name: cat.translations?.[0]?.name || categoryNames[directusLocale]?.[cat.key] || cat.key,
|
||||
items: itemsToUse.map((item: any) => ({
|
||||
items: itemsToUse.map((item: TechStackItem) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
@@ -370,8 +417,8 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
});
|
||||
|
||||
return categoriesWithItems;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch tech stack (${locale}):`, error);
|
||||
} catch (_error) {
|
||||
console.error(`Failed to fetch tech stack (${locale}):`, _error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -414,12 +461,23 @@ export async function getHobbies(locale: string): Promise<Hobby[] | null> {
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const hobbies = (result as any)?.hobbies;
|
||||
interface HobbiesResult {
|
||||
hobbies: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
icon: string;
|
||||
translations?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const hobbies = (result as HobbiesResult | null)?.hobbies;
|
||||
if (!hobbies || hobbies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hobbies.map((hobby: any) => ({
|
||||
return hobbies.map((hobby) => ({
|
||||
id: hobby.id,
|
||||
key: hobby.key,
|
||||
icon: hobby.icon,
|
||||
@@ -479,15 +537,30 @@ export async function getBookReviews(locale: string): Promise<BookReview[] | nul
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const reviews = (result as any)?.book_reviews;
|
||||
interface BookReviewsResult {
|
||||
book_reviews: Array<{
|
||||
id: string;
|
||||
hardcover_id?: string;
|
||||
book_title: string;
|
||||
book_author: string;
|
||||
book_image?: string;
|
||||
rating: number | string;
|
||||
finished_at?: string;
|
||||
translations?: Array<{
|
||||
review?: string;
|
||||
languages_code?: { code: string };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const reviews = (result as BookReviewsResult | null)?.book_reviews;
|
||||
if (!reviews || reviews.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return reviews.map((item: any) => {
|
||||
return reviews.map((item) => {
|
||||
// Filter die passende Übersetzung im Code
|
||||
const translation = item.translations?.find(
|
||||
(t: any) => t.languages_code?.code === directusLocale
|
||||
(t) => t.languages_code?.code === directusLocale
|
||||
) || item.translations?.[0]; // Fallback auf die erste Übersetzung falls locale nicht passt
|
||||
|
||||
return {
|
||||
@@ -509,7 +582,7 @@ export async function getBookReviews(locale: string): Promise<BookReview[] | nul
|
||||
|
||||
// Projects Types
|
||||
export interface Project {
|
||||
id: string;
|
||||
id: string | number; // Allow both string (from Directus) and number (from Prisma)
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -628,19 +701,52 @@ export async function getProjects(
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const projects = (result as any)?.projects;
|
||||
interface ProjectsResult {
|
||||
projects: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
category?: string;
|
||||
difficulty?: string;
|
||||
tags?: string[] | string;
|
||||
technologies?: string[] | string;
|
||||
challenges?: string;
|
||||
lessons_learned?: string;
|
||||
future_improvements?: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
image_url?: string;
|
||||
demo_video?: string;
|
||||
performance_metrics?: string;
|
||||
screenshots?: string[] | string;
|
||||
date_created?: string;
|
||||
date_updated?: string;
|
||||
featured?: boolean | number;
|
||||
status?: string;
|
||||
translations?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
meta_description?: string;
|
||||
keywords?: string;
|
||||
button_live_label?: string;
|
||||
button_github_label?: string;
|
||||
languages_code?: { code: string };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const projects = (result as ProjectsResult | null)?.projects;
|
||||
if (!projects || projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return projects.map((proj: any) => {
|
||||
return projects.map((proj) => {
|
||||
const trans =
|
||||
proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) ||
|
||||
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
||||
proj.translations?.[0] ||
|
||||
{};
|
||||
|
||||
// Parse JSON string fields if needed
|
||||
const parseTags = (tags: any) => {
|
||||
const parseTags = (tags: string[] | string | undefined): string[] => {
|
||||
if (!tags) return [];
|
||||
if (Array.isArray(tags)) return tags;
|
||||
if (typeof tags === 'string') {
|
||||
@@ -680,8 +786,8 @@ export async function getProjects(
|
||||
updated_at: proj.date_updated
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch projects (${locale}):`, error);
|
||||
} catch (_error) {
|
||||
console.error(`Failed to fetch projects (${locale}):`, _error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -743,19 +849,51 @@ export async function getProjectBySlug(
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const projects = (result as any)?.projects;
|
||||
interface ProjectResult {
|
||||
projects: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
category?: string;
|
||||
difficulty?: string;
|
||||
tags?: string[] | string;
|
||||
technologies?: string[] | string;
|
||||
challenges?: string;
|
||||
lessons_learned?: string;
|
||||
future_improvements?: string;
|
||||
github?: string;
|
||||
live?: string;
|
||||
image_url?: string;
|
||||
demo_video?: string;
|
||||
screenshots?: string[] | string;
|
||||
date_created?: string;
|
||||
date_updated?: string;
|
||||
featured?: boolean | number;
|
||||
status?: string;
|
||||
translations?: Array<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
meta_description?: string;
|
||||
keywords?: string;
|
||||
button_live_label?: string;
|
||||
button_github_label?: string;
|
||||
languages_code?: { code: string };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
const projects = (result as ProjectResult | null)?.projects;
|
||||
if (!projects || projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proj = projects[0];
|
||||
const trans =
|
||||
proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) ||
|
||||
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
||||
proj.translations?.[0] ||
|
||||
{};
|
||||
|
||||
// Parse JSON string fields if needed
|
||||
const parseTags = (tags: any) => {
|
||||
const parseTags = (tags: string[] | string | undefined): string[] => {
|
||||
if (!tags) return [];
|
||||
if (Array.isArray(tags)) return tags;
|
||||
if (typeof tags === 'string') {
|
||||
@@ -793,8 +931,65 @@ export async function getProjectBySlug(
|
||||
created_at: proj.date_created,
|
||||
updated_at: proj.date_updated
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch project by slug ${slug} (${locale}):`, error);
|
||||
} catch (_error) {
|
||||
console.error(`Failed to fetch project by slug ${slug} (${locale}):`, _error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets Types
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
code: string;
|
||||
description: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Snippets from Directus
|
||||
*/
|
||||
export async function getSnippets(limit = 10, featured?: boolean): Promise<Snippet[] | null> {
|
||||
const filters = ['status: { _eq: "published" }'];
|
||||
if (featured !== undefined) {
|
||||
filters.push(`featured: { _eq: ${featured} }`);
|
||||
}
|
||||
const filterString = `filter: { _and: [{ ${filters.join(' }, { ')} }] }`;
|
||||
|
||||
const query = `
|
||||
query {
|
||||
snippets(
|
||||
${filterString}
|
||||
limit: ${limit}
|
||||
) {
|
||||
id
|
||||
title
|
||||
category
|
||||
code
|
||||
description
|
||||
language
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await directusRequest(
|
||||
'',
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
interface SnippetsResult {
|
||||
snippets: Snippet[];
|
||||
}
|
||||
const snippets = (result as SnippetsResult | null)?.snippets;
|
||||
if (!snippets || snippets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snippets;
|
||||
} catch (_error) {
|
||||
console.error('Failed to fetch snippets:', _error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
* - Caches results (5 min TTL)
|
||||
*/
|
||||
|
||||
import { getMessage, getContentPage } from './directus';
|
||||
import { getMessage, getContentPage, ContentPage } from './directus';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import deMessages from '@/messages/de.json';
|
||||
|
||||
const jsonFallback = { en: enMessages, de: deMessages };
|
||||
|
||||
// Simple in-memory cache
|
||||
const cache = new Map<string, { value: any; expires: number }>();
|
||||
const cache = new Map<string, { value: unknown; expires: number }>();
|
||||
|
||||
function setCached(key: string, value: any, ttlSeconds = 300) {
|
||||
function setCached(key: string, value: unknown, ttlSeconds = 300) {
|
||||
cache.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||
}
|
||||
|
||||
function getCached(key: string): any | null {
|
||||
function getCached(key: string): unknown | null {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() > hit.expires) {
|
||||
@@ -38,7 +38,7 @@ export async function getLocalizedMessage(
|
||||
): Promise<string> {
|
||||
const cacheKey = `msg:${key}:${locale}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
if (cached !== null) return cached as string;
|
||||
|
||||
// Try Directus with requested locale
|
||||
const dbValue = await getMessage(key, locale);
|
||||
@@ -84,11 +84,11 @@ export async function getLocalizedMessage(
|
||||
export async function getLocalizedContent(
|
||||
slug: string,
|
||||
locale: string
|
||||
): Promise<any | null> {
|
||||
): Promise<ContentPage | null> {
|
||||
const cacheKey = `page:${slug}:${locale}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
if (cached === null && cache.has(cacheKey)) return null; // Already checked, not found
|
||||
if (cached !== null) return cached as ContentPage;
|
||||
if (cache.has(cacheKey)) return null; // Already checked, not found
|
||||
|
||||
// Try Directus with requested locale
|
||||
const dbPage = await getContentPage(slug, locale);
|
||||
@@ -115,14 +115,18 @@ export async function getLocalizedContent(
|
||||
* Helper: Get nested value from object
|
||||
* Example: "nav.home" → obj.nav.home
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): string | null {
|
||||
const keys = path.split('.');
|
||||
let value = obj;
|
||||
let value: unknown = obj;
|
||||
for (const key of keys) {
|
||||
value = value?.[key];
|
||||
if (value && typeof value === 'object' && key in value) {
|
||||
value = (value as Record<string, unknown>)[key];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (value === undefined) return null;
|
||||
}
|
||||
return value;
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
@@ -70,6 +70,13 @@
|
||||
"finishedAt": "Beendet am",
|
||||
"showMore": "{count} weitere anzeigen",
|
||||
"showLess": "Weniger anzeigen"
|
||||
},
|
||||
"activity": {
|
||||
"idleStatus": "System im Leerlauf / Geist aktiv",
|
||||
"codingNow": "Gerade am Coden",
|
||||
"gaming": "Am Zocken",
|
||||
"listening": "Hört gerade",
|
||||
"inGame": "Im Spiel"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
|
||||
@@ -71,6 +71,13 @@
|
||||
"finishedAt": "Finished",
|
||||
"showMore": "{count} more",
|
||||
"showLess": "Show less"
|
||||
},
|
||||
"activity": {
|
||||
"idleStatus": "System Idle / Mind Active",
|
||||
"codingNow": "Coding Now",
|
||||
"gaming": "Gaming",
|
||||
"listening": "Listening",
|
||||
"inGame": "In Game"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
|
||||
@@ -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",
|
||||
@@ -72,11 +70,15 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "dki.one",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Webpack configuration
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
webpack: (config, { dev, isServer, webpack }) => {
|
||||
// Fix for module resolution issues
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
@@ -103,6 +105,14 @@ const nextConfig: NextConfig = {
|
||||
maxSize: 200000, // keep chunks <200KB to avoid large-string serialization
|
||||
},
|
||||
};
|
||||
|
||||
// Suppress framer-motion source map errors in development
|
||||
config.plugins.push(
|
||||
new webpack.SourceMapDevToolPlugin({
|
||||
filename: "[file].map",
|
||||
exclude: [/framer-motion/, /LayoutGroupContext/],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,9 +124,9 @@ const nextConfig: NextConfig = {
|
||||
const csp =
|
||||
process.env.NODE_ENV === "production"
|
||||
? // Avoid `unsafe-eval` in production (reduces XSS impact and enables stronger CSP)
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
||||
: // Dev CSP: allow eval for tooling compatibility
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https:; connect-src 'self' https://*.dk0.dev; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:;"
|
||||
: // Dev CSP: allow eval for tooling compatibility, and localhost for HMR/API
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000 https://*.dk0.dev; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:;";
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -157,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",
|
||||
@@ -188,42 +226,4 @@ const withBundleAnalyzer = bundleAnalyzer({
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||
|
||||
// Wrap with Sentry
|
||||
export default withSentryConfig(
|
||||
withBundleAnalyzer(withNextIntl(nextConfig)),
|
||||
{
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: "dk0",
|
||||
project: "portfolio",
|
||||
|
||||
// Only print logs for uploading source maps in CI
|
||||
silent: !process.env.CI,
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Webpack-specific options
|
||||
webpack: {
|
||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
||||
reactComponentAnnotation: {
|
||||
enabled: true,
|
||||
},
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
treeshake: {
|
||||
removeDebugLogging: true,
|
||||
},
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors
|
||||
automaticVercelMonitors: true,
|
||||
},
|
||||
|
||||
// Source maps configuration
|
||||
sourcemaps: {
|
||||
disable: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
export default withBundleAnalyzer(withNextIntl(nextConfig));
|
||||
|
||||
548
package-lock.json
generated
548
package-lock.json
generated
@@ -10,10 +10,10 @@
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sentry/nextjs": "^10.36.0",
|
||||
"@shadergradient/react": "^2.4.20",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tiptap/extension-color": "^3.15.3",
|
||||
"@tiptap/extension-highlight": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
@@ -22,12 +22,10 @@
|
||||
"@tiptap/html": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"framer-motion": "^12.24.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.7",
|
||||
"next-intl": "^4.7.0",
|
||||
@@ -39,12 +37,10 @@
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"redis": "^5.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "^4.3.5"
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -58,8 +54,6 @@
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-responsive-masonry": "^2.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -629,12 +623,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@dimforge/rapier3d-compat": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
|
||||
@@ -2435,24 +2423,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mediapipe/tasks-vision": {
|
||||
"version": "0.10.17",
|
||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
|
||||
"integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@monogrid/gainmap-js": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
|
||||
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"promise-worker-transferable": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -3616,46 +3586,6 @@
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/drei": {
|
||||
"version": "10.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz",
|
||||
"integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@mediapipe/tasks-vision": "0.10.17",
|
||||
"@monogrid/gainmap-js": "^3.0.6",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"camera-controls": "^3.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"detect-gpu": "^5.0.56",
|
||||
"glsl-noise": "^0.0.0",
|
||||
"hls.js": "^1.5.17",
|
||||
"maath": "^0.10.8",
|
||||
"meshline": "^3.3.1",
|
||||
"stats-gl": "^2.2.8",
|
||||
"stats.js": "^0.17.0",
|
||||
"suspend-react": "^0.1.3",
|
||||
"three-mesh-bvh": "^0.8.3",
|
||||
"three-stdlib": "^2.35.6",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"tunnel-rat": "^0.1.2",
|
||||
"use-sync-external-store": "^1.4.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-three/fiber": "^9.0.0",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"three": ">=0.159"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-three/fiber": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz",
|
||||
@@ -4865,9 +4795,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
@@ -5483,12 +5413,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -5570,12 +5494,6 @@
|
||||
"@types/ms": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/draco3d": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz",
|
||||
"integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -5806,12 +5724,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/offscreencanvas": {
|
||||
"version": "2019.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
|
||||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.15.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
|
||||
@@ -5859,26 +5771,6 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-responsive-masonry": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-responsive-masonry/-/react-responsive-masonry-2.7.0.tgz",
|
||||
"integrity": "sha512-eMOxLcmPo3M8IDcTCmgK/luxjlJiqK1glZr15iM0+DYhL0QFlJvnNEgjhyOBGFlXsjlnLbcz1/M3/Q3fSeU1sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-syntax-highlighter": {
|
||||
"version": "15.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
|
||||
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sanitize-html": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.0.tgz",
|
||||
@@ -5896,12 +5788,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/tedious": {
|
||||
"version": "4.0.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz",
|
||||
@@ -5911,21 +5797,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": ">=0.5.17",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.22.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/tough-cookie": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||
@@ -6529,24 +6400,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@use-gesture/core": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz",
|
||||
"integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@use-gesture/react": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz",
|
||||
"integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@use-gesture/core": "10.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/og": {
|
||||
"version": "0.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/og/-/og-0.6.8.tgz",
|
||||
@@ -6722,12 +6575,6 @@
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.69",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
|
||||
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xtuc/ieee754": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
@@ -7401,15 +7248,6 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"require-from-string": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -7620,19 +7458,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camera-controls": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz",
|
||||
"integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.126.1"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001770",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
|
||||
@@ -8026,6 +7851,7 @@
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
|
||||
"integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.1"
|
||||
@@ -8352,15 +8178,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-gpu": {
|
||||
"version": "5.0.70",
|
||||
"resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz",
|
||||
"integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"webgl-constants": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -8540,12 +8357,6 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/draco3d": {
|
||||
"version": "1.5.7",
|
||||
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
|
||||
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -9282,6 +9093,7 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
@@ -9424,18 +9236,6 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend-shallow": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-extendable": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -9549,12 +9349,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||
@@ -9992,12 +9786,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/glsl-noise": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
|
||||
"integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -10017,43 +9805,6 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gray-matter": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.13.1",
|
||||
"kind-of": "^6.0.2",
|
||||
"section-matter": "^1.0.0",
|
||||
"strip-bom-string": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/gzip-size": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz",
|
||||
@@ -10254,12 +10005,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.6.15",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||
"integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz",
|
||||
@@ -10416,12 +10161,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -10754,15 +10493,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extendable": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
@@ -10929,12 +10659,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
@@ -12399,15 +12123,6 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/kind-of": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
@@ -12462,15 +12177,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -12619,16 +12325,6 @@
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/maath": {
|
||||
"version": "0.10.8",
|
||||
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
|
||||
"integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/three": ">=0.134.0",
|
||||
"three": ">=0.134.0"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -12898,21 +12594,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/meshline": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz",
|
||||
"integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.137"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -13697,6 +13378,15 @@
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -14647,12 +14337,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
|
||||
"integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -14720,16 +14404,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/promise-worker-transferable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"is-promise": "^2.1.0",
|
||||
"lie": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -15125,12 +14799,6 @@
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-responsive-masonry": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-responsive-masonry/-/react-responsive-masonry-2.7.1.tgz",
|
||||
"integrity": "sha512-Q+u+nOH87PzjqGFd2PgTcmLpHPZnCmUPREHYoNBc8dwJv6fi51p9U6hqwG8g/T8MN86HrFjrU+uQU6yvETU7cA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-use-measure": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||
@@ -15302,6 +14970,7 @@
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -15686,19 +15355,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/section-matter": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"extend-shallow": "^2.0.1",
|
||||
"kind-of": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -16008,6 +15664,7 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
@@ -16061,32 +15718,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
|
||||
"integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "^0.170.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/three": "*",
|
||||
"three": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl/node_modules/three": {
|
||||
"version": "0.170.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
|
||||
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stats.js": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz",
|
||||
"integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -16361,15 +15992,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
@@ -16775,41 +16397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz",
|
||||
"integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.159.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib": {
|
||||
"version": "2.36.1",
|
||||
"resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz",
|
||||
"integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/draco3d": "^1.4.0",
|
||||
"@types/offscreencanvas": "^2019.6.4",
|
||||
"@types/webxr": "^0.5.2",
|
||||
"draco3d": "^1.4.1",
|
||||
"fflate": "^0.6.9",
|
||||
"potpack": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.128.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three-stdlib/node_modules/fflate": {
|
||||
"version": "0.6.10",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
|
||||
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
|
||||
"version": "0.183.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.183.1.tgz",
|
||||
"integrity": "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
@@ -16902,36 +16492,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-text": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz",
|
||||
"integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bidi-js": "^1.0.2",
|
||||
"troika-three-utils": "^0.52.4",
|
||||
"troika-worker-utils": "^0.52.0",
|
||||
"webgl-sdf-generator": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-three-utils": {
|
||||
"version": "0.52.4",
|
||||
"resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz",
|
||||
"integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"three": ">=0.125.0"
|
||||
}
|
||||
},
|
||||
"node_modules/troika-worker-utils": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz",
|
||||
"integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trough": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
|
||||
@@ -17147,43 +16707,6 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-rat": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz",
|
||||
"integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zustand": "^4.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-rat/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -17599,15 +17122,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utility-types": {
|
||||
"version": "3.11.0",
|
||||
"resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz",
|
||||
"integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
@@ -17714,17 +17228,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
"integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg=="
|
||||
},
|
||||
"node_modules/webgl-sdf-generator": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz",
|
||||
"integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
@@ -18323,15 +17826,6 @@
|
||||
"integrity": "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
|
||||
24
package.json
24
package.json
@@ -54,10 +54,10 @@
|
||||
"dependencies": {
|
||||
"@next/bundle-analyzer": "^15.1.7",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@sentry/nextjs": "^10.36.0",
|
||||
"@shadergradient/react": "^2.4.20",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tiptap/extension-color": "^3.15.3",
|
||||
"@tiptap/extension-highlight": "^3.15.3",
|
||||
"@tiptap/extension-link": "^3.15.3",
|
||||
@@ -66,12 +66,10 @@
|
||||
"@tiptap/html": "^3.15.3",
|
||||
"@tiptap/react": "^3.15.3",
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@types/three": "^0.182.0",
|
||||
"@vercel/og": "^0.6.5",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"framer-motion": "^12.24.10",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "^15.5.7",
|
||||
"next-intl": "^4.7.0",
|
||||
@@ -83,13 +81,17 @@
|
||||
"react-dom": "^19.2.3",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"redis": "^5.8.2",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "^4.3.5"
|
||||
"three": "^0.183.1"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 2 Chrome versions",
|
||||
"last 2 Firefox versions",
|
||||
"last 2 Safari versions",
|
||||
"last 2 Edge versions"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.57.0",
|
||||
@@ -102,8 +104,6 @@
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-responsive-masonry": "^2.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -121,5 +121,11 @@
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "5.9.3",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"chrome >= 100",
|
||||
"firefox >= 100",
|
||||
"safari >= 15",
|
||||
"edge >= 100"
|
||||
]
|
||||
}
|
||||
|
||||
79
scripts/setup-snippets.js
Normal file
79
scripts/setup-snippets.js
Normal file
@@ -0,0 +1,79 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fetch = require('node-fetch');
|
||||
require('dotenv').config();
|
||||
|
||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||
|
||||
async function setupSnippets() {
|
||||
console.log('📦 Setting up Snippets collection...');
|
||||
|
||||
// 1. Create Collection
|
||||
try {
|
||||
await fetch(`${DIRECTUS_URL}/collections`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
collection: 'snippets',
|
||||
meta: { icon: 'terminal', display_template: '{{title}}' },
|
||||
schema: { name: 'snippets' }
|
||||
})
|
||||
});
|
||||
} catch (_e) {}
|
||||
|
||||
// 2. Add Fields
|
||||
const fields = [
|
||||
{ field: 'status', type: 'string', meta: { interface: 'select-dropdown' }, schema: { default_value: 'published' } },
|
||||
{ field: 'title', type: 'string', meta: { interface: 'input' } },
|
||||
{ field: 'category', type: 'string', meta: { interface: 'input' } },
|
||||
{ field: 'code', type: 'text', meta: { interface: 'input-code' } },
|
||||
{ field: 'description', type: 'text', meta: { interface: 'textarea' } },
|
||||
{ field: 'language', type: 'string', meta: { interface: 'input' }, schema: { default_value: 'javascript' } },
|
||||
{ field: 'featured', type: 'boolean', meta: { interface: 'boolean' }, schema: { default_value: false } }
|
||||
];
|
||||
|
||||
for (const f of fields) {
|
||||
try {
|
||||
await fetch(`${DIRECTUS_URL}/fields/snippets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(f)
|
||||
});
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
// 3. Add Example Data
|
||||
const exampleSnippets = [
|
||||
{
|
||||
title: 'Traefik SSL Config',
|
||||
category: 'Docker',
|
||||
language: 'yaml',
|
||||
featured: true,
|
||||
description: "Meine Standard-Konfiguration für automatisches SSL via Let's Encrypt in Docker Swarm.",
|
||||
code: "labels:\n - \"traefik.enable=true\"\n - \"traefik.http.routers.myapp.rule=Host(`example.com`)\"\n - \"traefik.http.routers.myapp.entrypoints=websecure\"\n - \"traefik.http.routers.myapp.tls.certresolver=myresolver\""
|
||||
},
|
||||
{
|
||||
title: 'Docker Cleanup Alias',
|
||||
category: 'ZSH',
|
||||
language: 'bash',
|
||||
featured: true,
|
||||
description: 'Ein einfacher Alias, um ungenutzte Docker-Container, Images und Volumes schnell zu entfernen.',
|
||||
code: "alias dclean='docker system prune -af --volumes'"
|
||||
}
|
||||
];
|
||||
|
||||
for (const s of exampleSnippets) {
|
||||
try {
|
||||
await fetch(`${DIRECTUS_URL}/items/snippets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(s)
|
||||
});
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
console.log('✅ Snippets setup complete!');
|
||||
}
|
||||
|
||||
setupSnippets();
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
/**
|
||||
* Container entrypoint: apply Prisma migrations, then start Next server.
|
||||
* Container entrypoint: wait for DB, apply Prisma migrations, then start Next server.
|
||||
*
|
||||
* Why:
|
||||
* - In real deployments you want schema changes applied automatically per deploy.
|
||||
@@ -8,8 +8,11 @@
|
||||
*
|
||||
* Controls:
|
||||
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
|
||||
* - DB_WAIT_RETRIES (default 15) and DB_WAIT_INTERVAL_MS (default 2000) control
|
||||
* how long to wait for the database before giving up.
|
||||
*/
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { createConnection } = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
@@ -24,18 +27,92 @@ function run(cmd, args, opts = {}) {
|
||||
throw res.error;
|
||||
}
|
||||
if (typeof res.status === "number" && res.status !== 0) {
|
||||
// propagate exit code
|
||||
process.exit(res.status);
|
||||
}
|
||||
}
|
||||
|
||||
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
|
||||
if (!skip) {
|
||||
/**
|
||||
* Wait for a TCP port to be reachable.
|
||||
* Parses DATABASE_URL to extract host and port.
|
||||
*/
|
||||
function waitForDb() {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
if (!dbUrl) {
|
||||
console.log("[startup] No DATABASE_URL set, skipping DB wait.");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let host, port;
|
||||
try {
|
||||
// postgresql://user:pass@host:port/db?schema=public
|
||||
const match = dbUrl.match(/@([^:/?]+):(\d+)/);
|
||||
if (!match) throw new Error("Could not parse host:port from DATABASE_URL");
|
||||
host = match[1];
|
||||
port = parseInt(match[2], 10);
|
||||
} catch (_err) {
|
||||
console.log("[startup] Could not parse DATABASE_URL, skipping DB wait.");
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const maxRetries = parseInt(process.env.DB_WAIT_RETRIES || "15", 10);
|
||||
const intervalMs = parseInt(process.env.DB_WAIT_INTERVAL_MS || "2000", 10);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempt = 0;
|
||||
|
||||
function tryConnect() {
|
||||
attempt++;
|
||||
const sock = createConnection({ host, port }, () => {
|
||||
sock.destroy();
|
||||
console.log(`[startup] Database at ${host}:${port} is reachable.`);
|
||||
resolve();
|
||||
});
|
||||
sock.setTimeout(1500);
|
||||
sock.on("error", () => {
|
||||
sock.destroy();
|
||||
if (attempt >= maxRetries) {
|
||||
console.error(
|
||||
`[startup] Database at ${host}:${port} not reachable after ${maxRetries} attempts. Proceeding anyway...`
|
||||
);
|
||||
resolve(); // still try migration - prisma will give a clear error
|
||||
} else {
|
||||
console.log(
|
||||
`[startup] Waiting for database at ${host}:${port}... (${attempt}/${maxRetries})`
|
||||
);
|
||||
setTimeout(tryConnect, intervalMs);
|
||||
}
|
||||
});
|
||||
sock.on("timeout", () => {
|
||||
sock.destroy();
|
||||
if (attempt >= maxRetries) {
|
||||
console.error(
|
||||
`[startup] Database at ${host}:${port} timed out after ${maxRetries} attempts. Proceeding anyway...`
|
||||
);
|
||||
resolve();
|
||||
} else {
|
||||
console.log(
|
||||
`[startup] Waiting for database at ${host}:${port}... (${attempt}/${maxRetries})`
|
||||
);
|
||||
setTimeout(tryConnect, intervalMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryConnect();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 1. Wait for database to be reachable
|
||||
await waitForDb();
|
||||
|
||||
// 2. Run migrations
|
||||
const skip =
|
||||
String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
|
||||
if (!skip) {
|
||||
const autoBaseline =
|
||||
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
|
||||
|
||||
// Avoid relying on `npx` resolution in minimal runtimes.
|
||||
// We copy `node_modules/prisma` into the runtime image.
|
||||
if (autoBaseline) {
|
||||
try {
|
||||
const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
|
||||
@@ -45,8 +122,6 @@ if (!skip) {
|
||||
.map((d) => d.name);
|
||||
const initMigration = entries.find((n) => n.endsWith("_init"));
|
||||
if (initMigration) {
|
||||
// This is the documented "baseline" flow for existing databases:
|
||||
// mark the initial migration as already applied.
|
||||
run("node", [
|
||||
"node_modules/prisma/build/index.js",
|
||||
"migrate",
|
||||
@@ -60,9 +135,12 @@ if (!skip) {
|
||||
}
|
||||
}
|
||||
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
|
||||
} else {
|
||||
} else {
|
||||
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
|
||||
}
|
||||
|
||||
// 3. Start the server
|
||||
run("node", ["server.js"]);
|
||||
}
|
||||
|
||||
run("node", ["server.js"]);
|
||||
|
||||
main();
|
||||
|
||||
163
scripts/update-hobbies.js
Normal file
163
scripts/update-hobbies.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const fetch = require('node-fetch');
|
||||
require('dotenv').config();
|
||||
|
||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||
|
||||
async function syncHobbies() {
|
||||
const hobbies = [
|
||||
{
|
||||
key: 'gym',
|
||||
icon: 'Activity',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Gym', description: 'Bin wieder regelmäßig im Training.' },
|
||||
{ languages_code: 'en-US', title: 'Gym', description: 'Back at training regularly.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'skiing',
|
||||
icon: 'Activity',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Skifahren', description: 'Ich liebe es, auf der Piste zu sein.' },
|
||||
{ languages_code: 'en-US', title: 'Skiing', description: 'Love being out on the slopes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'programming',
|
||||
icon: 'Code',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Programmieren', description: 'Mache ich einfach gerne, auch privat.' },
|
||||
{ languages_code: 'en-US', title: 'Programming', description: 'Just love building things, even in my free time.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'reading',
|
||||
icon: 'BookOpen',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Lesen', description: 'Lese einfach gerne zur Entspannung.' },
|
||||
{ languages_code: 'en-US', title: 'Reading', description: 'Enjoy reading to relax.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'gaming',
|
||||
icon: 'Gamepad2',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Zocken', description: 'Ab und zu mal eine Runde zocken.' },
|
||||
{ languages_code: 'en-US', title: 'Gaming', description: 'Playing some games every now and then.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'series',
|
||||
icon: 'Tv',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Serien', description: 'Ich schaue gerne gute Serien.' },
|
||||
{ languages_code: 'en-US', title: 'Series', description: 'I enjoy watching good series.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'boardgames',
|
||||
icon: 'Gamepad2',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Gesellschaftsspiele', description: 'Mag Gesellschaftsspiele mit Freunden.' },
|
||||
{ languages_code: 'en-US', title: 'Board Games', description: 'Love board games with friends.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'traveling',
|
||||
icon: 'Plane',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Reisen', description: 'Ich reise einfach gerne.' },
|
||||
{ languages_code: 'en-US', title: 'Traveling', description: 'I just love to travel.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'analog_photography',
|
||||
icon: 'Camera',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Analoge Fotografie', description: 'Lese mich gerade in das Thema ein.' },
|
||||
{ languages_code: 'en-US', title: 'Analog Photography', description: 'Currently reading into the topic.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'astronomy',
|
||||
icon: 'Stars',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Astronomie', description: 'Fasziniert vom Universum und Sternen.' },
|
||||
{ languages_code: 'en-US', title: 'Astronomy', description: 'Fascinated by the universe and stars.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'guitar',
|
||||
icon: 'Music',
|
||||
translations: [
|
||||
{ languages_code: 'de-DE', title: 'Gitarre', description: 'Spiele gelegentlich, wenn auch unregelmäßig.' },
|
||||
{ languages_code: 'en-US', title: 'Guitar', description: 'Playing occasionally, even if not regularly.' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
console.log('🔄 Syncing hobbies to Directus...');
|
||||
|
||||
for (const hobby of hobbies) {
|
||||
try {
|
||||
const searchRes = await fetch(`${DIRECTUS_URL}/items/hobbies?filter[key][_eq]=${hobby.key}`, {
|
||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}` }
|
||||
});
|
||||
const searchData = await searchRes.json();
|
||||
|
||||
if (searchData.data && searchData.data.length > 0) {
|
||||
const id = searchData.data[0].id;
|
||||
console.log(`Updating ${hobby.key}...`);
|
||||
await fetch(`${DIRECTUS_URL}/items/hobbies/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: 'published',
|
||||
icon: hobby.icon,
|
||||
translations: hobby.translations
|
||||
})
|
||||
});
|
||||
} else {
|
||||
console.log(`Creating ${hobby.key}...`);
|
||||
await fetch(`${DIRECTUS_URL}/items/hobbies`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key: hobby.key,
|
||||
status: 'published',
|
||||
icon: hobby.icon,
|
||||
translations: hobby.translations
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (_e) {
|
||||
console.error(`Failed to sync ${hobby.key}:`, _e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete 'gameServers' if it exists
|
||||
try {
|
||||
const delSearch = await fetch(`${DIRECTUS_URL}/items/hobbies?filter[key][_eq]=gameServers`, {
|
||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}` }
|
||||
});
|
||||
const delData = await delSearch.json();
|
||||
if (delData.data && delData.data.length > 0) {
|
||||
console.log('Removing gameServers hobby...');
|
||||
await fetch(`${DIRECTUS_URL}/items/hobbies/${delData.data[0].id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}` }
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
console.log('✅ Done!');
|
||||
}
|
||||
|
||||
syncHobbies();
|
||||
@@ -5,12 +5,6 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
// DSN from environment variable with fallback to wizard-provided value
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
// DSN from environment variable with fallback to wizard-provided value
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
||||
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user