diff --git a/.claude/agents/backend-dev.md b/.claude/agents/backend-dev.md new file mode 100644 index 0000000..8ec2066 --- /dev/null +++ b/.claude/agents/backend-dev.md @@ -0,0 +1,45 @@ +--- +name: backend-dev +description: Backend API developer for this portfolio. Use proactively when implementing API routes, Prisma/PostgreSQL queries, Directus CMS integration, n8n webhook proxies, Redis caching, or anything in app/api/ or lib/. Handles graceful fallbacks and rate limiting. +tools: Read, Edit, Write, Bash, Grep, Glob +model: sonnet +permissionMode: acceptEdits +--- + +You are a senior backend developer for Dennis Konkol's portfolio (dk0.dev). + +## Stack you own +- **Next.js 15 API routes** in `app/api/` +- **Prisma ORM** + PostgreSQL (schema in `prisma/schema.prisma`) +- **Directus GraphQL** via `lib/directus.ts` — no Directus SDK; uses `directusRequest()` with 2s timeout +- **n8n webhook proxies** in `app/api/n8n/` +- **Redis** caching (optional, graceful if unavailable) +- **Rate limiting + auth** via `lib/auth.ts` + +## File ownership +`app/api/`, `lib/`, `prisma/`, `scripts/` + +## API route conventions (always required) +```typescript +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +``` +Every route must include a `source` field in the response: `"directus"` | `"fallback"` | `"error"` + +## Data source fallback chain (must follow) +1. Directus CMS (if `DIRECTUS_STATIC_TOKEN` set) → 2. PostgreSQL → 3. `messages/*.json` → 4. Hardcoded defaults + +All external calls (Directus, n8n, Redis) must have try/catch with graceful null fallback — the site must never crash if a service is down. + +## When implementing a feature +1. Read `lib/directus.ts` to check for existing GraphQL query patterns +2. Add GraphQL query + TypeScript types to `lib/directus.ts` for new Directus collections +3. All POST/PUT endpoints need input validation +4. n8n proxies need rate limiting and 10s timeout +5. Error logging: `if (process.env.NODE_ENV === "development") console.error(...)` +6. Run `npm run build` to verify TypeScript compiles without errors +7. After schema changes, run `npm run db:generate` + +## Directus collections +`tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews` +Locale mapping: `en` → `en-US`, `de` → `de-DE` diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md new file mode 100644 index 0000000..e95d5a6 --- /dev/null +++ b/.claude/agents/code-reviewer.md @@ -0,0 +1,52 @@ +--- +name: code-reviewer +description: Expert code reviewer for this portfolio. Use proactively immediately after writing or modifying code. Reviews for SSR safety, accessibility contrast, TypeScript strictness, graceful fallbacks, and Conventional Commits. +tools: Read, Grep, Glob, Bash +model: inherit +--- + +You are a senior code reviewer for Dennis Konkol's portfolio (dk0.dev). You are read-only — you report issues but do not fix them. + +## When invoked +1. Run `git diff HEAD` to see all recent changes +2. For each modified file, read it fully before commenting +3. Begin your review immediately — no clarifying questions + +## Review checklist + +### SSR Safety (critical) +- [ ] No `initial={{ opacity: 0 }}` on server-rendered elements (use `ScrollFadeIn` instead) +- [ ] No bare `window`/`document`/`localStorage` outside `useEffect` or `hasMounted` check +- [ ] `"use client"` directive present on components using hooks or browser APIs + +### TypeScript +- [ ] No `any` types — use interfaces from `lib/directus.ts` or `types/` +- [ ] Async components properly typed + +### API Routes +- [ ] `export const runtime = 'nodejs'` and `dynamic = 'force-dynamic'` present +- [ ] `source` field in JSON response (`"directus"` | `"fallback"` | `"error"`) +- [ ] Try/catch with graceful fallback on all external calls +- [ ] Error logging behind `process.env.NODE_ENV === "development"` guard + +### Design System +- [ ] Only `liquid-*` color tokens used, no hardcoded colors +- [ ] Body text uses `text-stone-600 dark:text-stone-400` (not `text-stone-400` alone) +- [ ] New async components have a Skeleton loading state + +### i18n +- [ ] New user-facing strings added to both `messages/en.json` AND `messages/de.json` +- [ ] Server components use `getTranslations()`, client components use `useTranslations()` + +### General +- [ ] No `console.error` outside dev guard +- [ ] No emojis in code +- [ ] Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`) + +## Output format +Group findings by severity: +- **Critical** — must fix before merge (SSR invisibility, security, crashes) +- **Warning** — should fix (TypeScript issues, missing fallbacks) +- **Suggestion** — nice to have + +Include file path, line number, and concrete fix example for each issue. diff --git a/.claude/agents/debugger.md b/.claude/agents/debugger.md new file mode 100644 index 0000000..49c70ec --- /dev/null +++ b/.claude/agents/debugger.md @@ -0,0 +1,48 @@ +--- +name: debugger +description: Debugging specialist for this portfolio. Use proactively when encountering build errors, test failures, hydration mismatches, invisible content, or any unexpected behavior. Specializes in Next.js SSR issues, Prisma connection errors, and Docker deployment failures. +tools: Read, Edit, Bash, Grep, Glob +model: opus +--- + +You are an expert debugger for Dennis Konkol's portfolio (dk0.dev). You specialize in root cause analysis — fix the cause, not the symptom. + +## Common issue categories for this project + +### Invisible/hidden content +- Check for `initial={{ opacity: 0 }}` on SSR-rendered Framer Motion elements +- Check if `ScrollFadeIn` `hasMounted` guard is working (component renders with styles before mount) +- Check for CSS specificity issues with Tailwind dark mode + +### Hydration mismatches +- Look for `typeof window !== "undefined"` checks used incorrectly +- Check if server/client rendered different HTML (dates, random values, user state) +- Look for missing `suppressHydrationWarning` on elements with intentional server/client differences + +### Build failures +- Check TypeScript errors: `npm run build` for full output +- Check for missing `"use client"` on components using hooks +- Check for circular imports + +### Test failures +- Check if new ESM packages need to be added to `transformIgnorePatterns` in `jest.config.ts` +- Verify mocks in `jest.setup.ts` match what the component expects +- For server component tests, use `const resolved = await Component(props); render(resolved)` + +### Database issues +- Prisma client regeneration: `npm run db:generate` +- Check `DATABASE_URL` in `.env.local` +- `prisma db push` for schema sync (development only) + +### Docker/deployment issues +- Standalone build required: verify `output: "standalone"` in `next.config.ts` +- Check `scripts/start-with-migrate.js` entrypoint logs +- Dev and production share PostgreSQL and Redis — check for migration conflicts + +## Debugging process +1. Read the full error including stack trace +2. Run `git log --oneline -5` and `git diff HEAD~1` to check recent changes +3. Form a hypothesis before touching any code +4. Make the minimal fix that addresses the root cause +5. Verify: `npm run build && npm run test` +6. Explain: root cause, fix applied, prevention strategy diff --git a/.claude/agents/frontend-dev.md b/.claude/agents/frontend-dev.md new file mode 100644 index 0000000..d01977d --- /dev/null +++ b/.claude/agents/frontend-dev.md @@ -0,0 +1,39 @@ +--- +name: frontend-dev +description: Frontend React/Next.js developer for this portfolio. Use proactively when implementing UI components, pages, scroll animations, or anything in app/components/ or app/[locale]/. Expert in Tailwind liquid-* tokens, Framer Motion, next-intl, and SSR safety. +tools: Read, Edit, Write, Bash, Grep, Glob +model: sonnet +permissionMode: acceptEdits +--- + +You are a senior frontend developer for Dennis Konkol's portfolio (dk0.dev). + +## Stack you own +- **Next.js 15 App Router** with React 19 and TypeScript (strict — no `any`) +- **Tailwind CSS** using `liquid-*` color tokens only: `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`, `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime` +- **Framer Motion 12** — variants pattern with `staggerContainer` + `fadeInUp` +- **next-intl** for i18n (always add keys to both `messages/en.json` and `messages/de.json`) +- **next-themes** for dark mode support + +## File ownership +`app/components/`, `app/_ui/`, `app/[locale]/`, `messages/` + +## Design rules +- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm border-2 rounded-xl` +- Headlines: uppercase, `tracking-tighter`, accent dot at end: `.` +- Body text: `text-stone-600 dark:text-stone-400` — minimum contrast 4.5:1 (never use `text-stone-400` alone) +- Layout: Bento Grid, no floating overlays +- Every async component must have a Skeleton loading state + +## SSR animation safety (critical) +**Never** use `initial={{ opacity: 0 }}` on SSR-rendered elements — it bakes invisible HTML. +Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) for scroll animations instead. +`AnimatePresence` is fine only for modals/overlays (client-only). + +## When implementing a feature +1. Read existing similar components first with Grep before writing new code +2. Client components need `"use client"` directive +3. Server components use `getTranslations()` from `next-intl/server`; client components use `useTranslations()` +4. New client sections must get a wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider` +5. Add to `app/_ui/HomePageServer.tsx` wrapped in `` +6. Run `npm run lint` before finishing — 0 errors required diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md new file mode 100644 index 0000000..5c5316d --- /dev/null +++ b/.claude/agents/tester.md @@ -0,0 +1,49 @@ +--- +name: tester +description: Test automation specialist for this portfolio. Use proactively after implementing any feature or bug fix to write Jest unit tests and Playwright E2E tests. Knows all JSDOM quirks and mock patterns specific to this project. +tools: Read, Edit, Write, Bash, Grep, Glob +model: sonnet +--- + +You are a test automation engineer for Dennis Konkol's portfolio (dk0.dev). + +## Test stack +- **Jest** with JSDOM for unit/integration tests (`npm run test`) +- **Playwright** for E2E tests (`npm run test:e2e`) +- **@testing-library/react** for component rendering + +## Known mock setup (in jest.setup.ts) +These are already mocked globally — do NOT re-mock them in individual tests: +- `window.matchMedia` +- `window.IntersectionObserver` +- `NextResponse.json` +- `Headers`, `Request`, `Response` (polyfilled from node-fetch) + +Test env vars pre-set: `DIRECTUS_URL=http://localhost:8055`, `NEXT_PUBLIC_SITE_URL=http://localhost:3000` + +## ESM gotcha +If adding new ESM-only packages to tests, check `transformIgnorePatterns` in `jest.config.ts` — packages like `react-markdown` and `remark-*` need to be listed there. + +## Server component test pattern +```typescript +const resolved = await MyServerComponent({ locale: 'en' }) +render(resolved) +``` + +## `next/image` in tests +Use a simple `` with `eslint-disable-next-line @next/next/no-img-element` — don't try to mock next/image. + +## When writing tests +1. Read the component/function being tested first +2. Identify: happy path, error path, edge cases, SSR rendering +3. Mock ALL external API calls (Directus, n8n, PostgreSQL) +4. Run `npx jest path/to/test.tsx` to verify the specific test passes +5. Run `npm run test` to verify no regressions +6. Report final coverage for the new code + +## File ownership +`__tests__/`, `app/**/__tests__/`, `e2e/`, `jest.config.ts`, `jest.setup.ts` + +## E2E test files +`e2e/critical-paths.spec.ts`, `e2e/hydration.spec.ts`, `e2e/accessibility.spec.ts`, `e2e/performance.spec.ts` +Run specific: `npm run test:critical`, `npm run test:hydration`, `npm run test:accessibility` diff --git a/.claude/rules/api-routes.md b/.claude/rules/api-routes.md new file mode 100644 index 0000000..fff14f8 --- /dev/null +++ b/.claude/rules/api-routes.md @@ -0,0 +1,35 @@ +--- +paths: + - "app/api/**/*.ts" +--- + +# API Route Rules + +Every API route in this project must follow these conventions: + +## Required exports +```typescript +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +``` + +## Response format +All responses must include a `source` field: +```typescript +return NextResponse.json({ data: ..., source: 'directus' | 'fallback' | 'error' }) +``` + +## Error handling +- Wrap all external calls (Directus, n8n, Redis, PostgreSQL) in try/catch +- Return graceful fallback data on failure — never let an external service crash the page +- Error logging: `if (process.env.NODE_ENV === "development") console.error(...)` + +## n8n proxies (app/api/n8n/) +- Rate limiting required on all public endpoints (use `lib/auth.ts`) +- 10 second timeout on upstream n8n calls +- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers + +## Directus queries +- Use `directusRequest()` from `lib/directus.ts` +- 2 second timeout is already set in `directusRequest()` +- Always have a hardcoded fallback when Directus returns null diff --git a/.claude/rules/components.md b/.claude/rules/components.md new file mode 100644 index 0000000..d7976e9 --- /dev/null +++ b/.claude/rules/components.md @@ -0,0 +1,37 @@ +--- +paths: + - "app/components/**/*.tsx" + - "app/_ui/**/*.tsx" +--- + +# Component Rules + +## SSR animation safety (critical) +**Never** use `initial={{ opacity: 0 }}` on server-rendered elements. +This bakes `style="opacity:0"` into HTML — content is invisible if hydration fails. + +Use `ScrollFadeIn` instead: +```tsx +import ScrollFadeIn from "@/app/components/ScrollFadeIn" + +``` + +`AnimatePresence` is fine for modals and overlays that only appear after user interaction. + +## Design system +- Colors: only `liquid-*` tokens — no hardcoded hex or raw Tailwind palette colors +- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl` +- Headlines: `uppercase tracking-tighter` with accent dot `.` +- Body text: `text-stone-600 dark:text-stone-400` — never `text-stone-400` alone (fails contrast) + +## Async components +Every component that fetches data must have a Skeleton loading state shown while data loads. + +## i18n +- Client: `useTranslations("namespace")` from `next-intl` +- Server: `getTranslations("namespace")` from `next-intl/server` +- New client sections need a wrapper in `ClientWrappers.tsx` with scoped `NextIntlClientProvider` + +## TypeScript +- No `any` — define interfaces in `lib/directus.ts` or `types/` +- No emojis in code diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..01909a5 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,38 @@ +--- +paths: + - "**/__tests__/**/*.ts" + - "**/__tests__/**/*.tsx" + - "**/*.test.ts" + - "**/*.test.tsx" + - "e2e/**/*.spec.ts" +--- + +# Testing Rules + +## Jest environment +- Global mocks are set up in `jest.setup.ts` — do NOT re-mock `matchMedia`, `IntersectionObserver`, or `NextResponse` in individual tests +- Test env vars are pre-set: `DIRECTUS_URL`, `NEXT_PUBLIC_SITE_URL` +- Always mock external API calls (Directus, n8n, PostgreSQL) — tests must work without running services + +## ESM modules +If a new import causes "Must use import to load ES Module" errors, add the package to `transformIgnorePatterns` in `jest.config.ts`. + +## Server component tests +```typescript +// Server components return JSX, not a promise in React 19, but async ones need await +const resolved = await MyServerComponent({ locale: 'en', ...props }) +render(resolved) +``` + +## next/image in tests +Replace `next/image` with a plain `` in test renders: +```tsx +// eslint-disable-next-line @next/next/no-img-element +{alt} +``` + +## Run commands +- Single file: `npx jest path/to/test.tsx` +- All unit tests: `npm run test` +- Watch mode: `npm run test:watch` +- Specific E2E: `npm run test:critical`, `npm run test:hydration`, `npm run test:accessibility` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1c584fc --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "FILE=$(echo $CLAUDE_TOOL_INPUT | jq -r '.file_path // empty'); if [ -n \"$FILE\" ] && echo \"$FILE\" | grep -qE '\\.(ts|tsx|js|jsx)$'; then npx eslint --fix \"$FILE\" 2>/dev/null || true; fi" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "osascript -e 'display notification \"Claude ist fertig\" with title \"Claude Code\" sound name \"Glass\"' 2>/dev/null || true" + } + ] + } + ] + } +} diff --git a/.claude/skills/add-section/SKILL.md b/.claude/skills/add-section/SKILL.md new file mode 100644 index 0000000..473cb71 --- /dev/null +++ b/.claude/skills/add-section/SKILL.md @@ -0,0 +1,50 @@ +--- +name: add-section +description: Orchestrate adding a new CMS-managed section to the portfolio following the full 6-step pattern +context: fork +agent: general-purpose +--- + +Add a new CMS-managed section called "$ARGUMENTS" to the portfolio. + +Follow the exact 6-step pattern from CLAUDE.md: + +**Step 1 — lib/directus.ts** +Read `lib/directus.ts` first, then add: +- TypeScript interface for the new collection +- `directusRequest()` GraphQL query for the collection (with translation support if needed) +- Export the fetch function + +**Step 2 — API Route** +Create `app/api/$ARGUMENTS/route.ts`: +- `export const runtime = 'nodejs'` +- `export const dynamic = 'force-dynamic'` +- Try Directus first, fallback to hardcoded defaults +- Include `source: "directus" | "fallback" | "error"` in response +- Error logging behind `process.env.NODE_ENV === "development"` guard + +**Step 3 — Component** +Create `app/components/$ARGUMENTS.tsx`: +- `"use client"` directive +- Skeleton loading state for the async data +- Tailwind liquid-* tokens for styling (cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl`) +- Headline uppercase with tracking-tighter and emerald accent dot + +**Step 4 — i18n** +Add translation keys to both: +- `messages/en.json` +- `messages/de.json` + +**Step 5 — Client Wrapper** +Add `${ARGUMENTS}Client` to `app/components/ClientWrappers.tsx`: +- Wrap in scoped `NextIntlClientProvider` with only the needed translation namespace + +**Step 6 — Homepage Integration** +Add to `app/_ui/HomePageServer.tsx`: +- Fetch translations in the existing `Promise.all` +- Render wrapped in `` + +After implementation: +- Run `npm run lint` — must be 0 errors +- Run `npm run build` — must compile successfully +- Report what was created and any manual steps remaining (e.g., creating the Directus collection) diff --git a/.claude/skills/check-quality/SKILL.md b/.claude/skills/check-quality/SKILL.md new file mode 100644 index 0000000..517cfc5 --- /dev/null +++ b/.claude/skills/check-quality/SKILL.md @@ -0,0 +1,39 @@ +--- +name: check-quality +description: Run all quality checks (lint, build, tests) and report a summary of the project's health +disable-model-invocation: false +--- + +Run all quality checks for this portfolio project and report the results. + +Execute these checks in order: + +**1. ESLint** +Run: `npm run lint` +Required: 0 errors (warnings OK) + +**2. TypeScript** +Run: `npx tsc --noEmit` +Required: 0 type errors + +**3. Unit Tests** +Run: `npm run test -- --passWithNoTests` +Report: pass/fail count and any failing test names + +**4. Production Build** +Run: `npm run build` +Required: successful completion + +**5. i18n Parity Check** +Compare keys in `messages/en.json` vs `messages/de.json` — report any keys present in one but not the other. + +After all checks, produce a summary table: +| Check | Status | Details | +|-------|--------|---------| +| ESLint | ✓/✗ | ... | +| TypeScript | ✓/✗ | ... | +| Tests | ✓/✗ | X passed, Y failed | +| Build | ✓/✗ | ... | +| i18n parity | ✓/✗ | Missing keys: ... | + +If anything fails, provide the specific error and a recommended fix. diff --git a/.claude/skills/review-changes/SKILL.md b/.claude/skills/review-changes/SKILL.md new file mode 100644 index 0000000..62949f8 --- /dev/null +++ b/.claude/skills/review-changes/SKILL.md @@ -0,0 +1,30 @@ +--- +name: review-changes +description: Run a thorough code review on all recent uncommitted changes using the code-reviewer agent +context: fork +agent: code-reviewer +--- + +Review all recent changes in this repository. + +First gather context: +- Recent changes: !`git diff HEAD` +- Staged changes: !`git diff --cached` +- Modified files: !`git status --short` +- Recent commits: !`git log --oneline -5` + +Then perform a full code review using the code-reviewer agent checklist: +- SSR safety (no `initial={{ opacity: 0 }}` on server elements) +- TypeScript strictness (no `any`) +- API route conventions (`runtime`, `dynamic`, `source` field) +- Design system compliance (liquid-* tokens, contrast ratios) +- i18n completeness (both en.json and de.json) +- Error logging guards +- Graceful fallbacks on all external calls + +Output: +- **Critical** issues (must fix before merge) +- **Warnings** (should fix) +- **Suggestions** (nice to have) + +Include file:line references and concrete fix examples for each issue. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dce0be2..9978f7e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Gitea CI +name: CI / CD on: push: @@ -6,7 +6,12 @@ on: pull_request: branches: [main, dev, production] +env: + NODE_VERSION: '25' + DOCKER_IMAGE: portfolio-app + jobs: + # ── Job 1: Lint, Test, Build (runs on every push/PR) ── test-build: runs-on: ubuntu-latest steps: @@ -16,10 +21,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: ${{ env.NODE_VERSION }} cache: 'npm' - - name: Install deps + - name: Install dependencies run: npm ci - name: Lint @@ -28,5 +33,247 @@ jobs: - name: Test run: npm run test - - name: Build - run: npm run build + - name: Type check + run: npx tsc --noEmit + + # ── Job 2: Deploy to dev (only on dev branch, after tests pass) ── + deploy-dev: + needs: test-build + if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + echo "🏗️ Building dev Docker image..." + DOCKER_BUILDKIT=1 docker build \ + --cache-from ${{ env.DOCKER_IMAGE }}:dev \ + --cache-from ${{ env.DOCKER_IMAGE }}:latest \ + -t ${{ env.DOCKER_IMAGE }}:dev \ + . + echo "✅ Docker image built successfully" + + - name: Deploy dev container + run: | + echo "🚀 Starting dev deployment..." + + CONTAINER_NAME="portfolio-app-dev" + HEALTH_PORT="3001" + IMAGE_NAME="${{ env.DOCKER_IMAGE }}:dev" + + # Check for existing container + EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "") + + # Ensure networks exist + echo "🌐 Ensuring networks exist..." + docker network create portfolio_net 2>/dev/null || true + docker network create proxy 2>/dev/null || true + + # Verify production DB is reachable + if docker exec portfolio-postgres pg_isready -U portfolio_user -d portfolio_db >/dev/null 2>&1; then + echo "✅ Production database is ready!" + else + echo "⚠️ Production database not reachable, app will use fallbacks" + fi + + # Stop and remove existing container + if [ ! -z "$EXISTING_CONTAINER" ]; then + echo "🛑 Stopping existing container..." + docker stop $EXISTING_CONTAINER 2>/dev/null || true + docker rm $EXISTING_CONTAINER 2>/dev/null || true + sleep 3 + fi + + # Ensure port is free + PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->)" | awk '{print $1}' | head -1 || echo "") + if [ ! -z "$PORT_CONTAINER" ]; then + echo "⚠️ Port ${HEALTH_PORT} still in use, freeing..." + docker stop $PORT_CONTAINER 2>/dev/null || true + docker rm $PORT_CONTAINER 2>/dev/null || true + sleep 3 + fi + + # Start new container + echo "🆕 Starting new dev container..." + docker run -d \ + --name $CONTAINER_NAME \ + --restart unless-stopped \ + --network portfolio_net \ + -p ${HEALTH_PORT}:3000 \ + -e NODE_ENV=production \ + -e LOG_LEVEL=${LOG_LEVEL:-debug} \ + -e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \ + -e DATABASE_URL="${DATABASE_URL}" \ + -e REDIS_URL="${REDIS_URL}" \ + -e MY_EMAIL="${MY_EMAIL}" \ + -e MY_INFO_EMAIL="${MY_INFO_EMAIL}" \ + -e MY_PASSWORD="${MY_PASSWORD}" \ + -e MY_INFO_PASSWORD="${MY_INFO_PASSWORD}" \ + -e ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}" \ + -e ADMIN_SESSION_SECRET="${ADMIN_SESSION_SECRET}" \ + -e N8N_WEBHOOK_URL="${N8N_WEBHOOK_URL}" \ + -e N8N_SECRET_TOKEN="${N8N_SECRET_TOKEN}" \ + -e N8N_API_KEY="${N8N_API_KEY}" \ + -e DIRECTUS_URL="${DIRECTUS_URL}" \ + -e DIRECTUS_STATIC_TOKEN="${DIRECTUS_STATIC_TOKEN}" \ + $IMAGE_NAME + + # Connect to proxy network + docker network connect proxy $CONTAINER_NAME 2>/dev/null || true + + # Wait for health + echo "⏳ Waiting for container to be healthy..." + for i in {1..60}; do + if curl -f -s http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then + echo "✅ Dev container is healthy!" + break + fi + HEALTH=$(docker inspect $CONTAINER_NAME --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") + if [ "$HEALTH" == "healthy" ]; then + echo "✅ Docker health check passed!" + break + fi + if [ $i -eq 60 ]; then + echo "⚠️ Health check timed out, showing logs:" + docker logs $CONTAINER_NAME --tail=30 + fi + sleep 2 + done + + echo "✅ Dev deployment completed!" + env: + LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }} + NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} + DATABASE_URL: postgresql://portfolio_user:portfolio_pass@portfolio-postgres:5432/portfolio_db?schema=public + REDIS_URL: redis://portfolio-redis:6379 + MY_EMAIL: ${{ vars.MY_EMAIL }} + MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} + MY_PASSWORD: ${{ secrets.MY_PASSWORD }} + MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} + ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} + ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} + N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} + N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} + N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} + DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }} + DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }} + + - name: Cleanup + run: docker image prune -f + + # ── Job 3: Deploy to production (only on production branch, after tests pass) ── + deploy-production: + needs: test-build + if: github.ref == 'refs/heads/production' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + echo "🏗️ Building production Docker image..." + DOCKER_BUILDKIT=1 docker build \ + --cache-from ${{ env.DOCKER_IMAGE }}:production \ + --cache-from ${{ env.DOCKER_IMAGE }}:latest \ + -t ${{ env.DOCKER_IMAGE }}:production \ + -t ${{ env.DOCKER_IMAGE }}:latest \ + . + echo "✅ Docker image built successfully" + + - name: Deploy production container + run: | + echo "🚀 Starting production deployment..." + + COMPOSE_FILE="docker-compose.production.yml" + CONTAINER_NAME="portfolio-app" + HEALTH_PORT="3000" + + # Backup current container ID + OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "") + + # Ensure network exists + docker network create portfolio_net 2>/dev/null || true + + # Export variables for docker-compose + export N8N_WEBHOOK_URL="${N8N_WEBHOOK_URL}" + export N8N_SECRET_TOKEN="${N8N_SECRET_TOKEN}" + export N8N_API_KEY="${N8N_API_KEY}" + export MY_EMAIL="${MY_EMAIL}" + export MY_INFO_EMAIL="${MY_INFO_EMAIL}" + export MY_PASSWORD="${MY_PASSWORD}" + export MY_INFO_PASSWORD="${MY_INFO_PASSWORD}" + export ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}" + export ADMIN_SESSION_SECRET="${ADMIN_SESSION_SECRET}" + export DIRECTUS_URL="${DIRECTUS_URL}" + export DIRECTUS_STATIC_TOKEN="${DIRECTUS_STATIC_TOKEN}" + + # Start new container via compose + echo "🆕 Starting new production container..." + docker compose -f $COMPOSE_FILE up -d portfolio + + # Wait for health + echo "⏳ Waiting for container to be healthy..." + HEALTH_CHECK_PASSED=false + for i in {1..90}; do + NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) + if [ -z "$NEW_CONTAINER" ]; then + NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") + fi + if [ ! -z "$NEW_CONTAINER" ]; then + HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") + if [ "$HEALTH" == "healthy" ]; then + echo "✅ Production container is healthy!" + HEALTH_CHECK_PASSED=true + break + fi + if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then + echo "✅ Production HTTP health check passed!" + HEALTH_CHECK_PASSED=true + break + fi + fi + if [ $((i % 15)) -eq 0 ]; then + echo "📊 Health: ${HEALTH:-unknown} (attempt $i/90)" + docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true + fi + sleep 2 + done + + if [ "$HEALTH_CHECK_PASSED" != "true" ]; then + echo "❌ Production health check failed!" + docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || true + exit 1 + fi + + # Remove old container if different + if [ ! -z "$OLD_CONTAINER" ]; then + NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") + if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then + echo "🧹 Removing old container..." + docker stop $OLD_CONTAINER 2>/dev/null || true + docker rm $OLD_CONTAINER 2>/dev/null || true + fi + fi + + echo "✅ Production deployment completed!" + env: + NODE_ENV: production + LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} + NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} + MY_EMAIL: ${{ vars.MY_EMAIL }} + MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} + MY_PASSWORD: ${{ secrets.MY_PASSWORD }} + MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} + ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} + ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} + N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} + N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} + N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} + DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }} + DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }} + + - name: Cleanup + run: docker image prune -f diff --git a/.gitea/workflows/dev-deploy.yml.disabled b/.gitea/workflows/dev-deploy.yml.disabled deleted file mode 100644 index bc7904b..0000000 --- a/.gitea/workflows/dev-deploy.yml.disabled +++ /dev/null @@ -1,300 +0,0 @@ -name: Dev Deployment (Zero Downtime) - -on: - push: - branches: [ dev ] - -env: - NODE_VERSION: '25' - DOCKER_IMAGE: portfolio-app - IMAGE_TAG: dev - -jobs: - deploy-dev: - runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run linting - run: npm run lint - continue-on-error: true # Don't block dev deployments on lint errors - - - name: Run tests - run: npm run test - continue-on-error: true # Don't block dev deployments on test failures - - - name: Build application - run: npm run build - - - name: Build Docker image - run: | - echo "🏗️ Building dev Docker image with BuildKit cache..." - DOCKER_BUILDKIT=1 docker build \ - --cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ - --cache-from ${{ env.DOCKER_IMAGE }}:latest \ - -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ - . - echo "✅ Docker image built successfully" - - - name: Zero-Downtime Dev Deployment - run: | - echo "🚀 Starting zero-downtime dev deployment..." - - CONTAINER_NAME="portfolio-app-dev" - HEALTH_PORT="3001" - IMAGE_NAME="${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}" - - # Check for existing container (running or stopped) - EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "") - - # Start DB and Redis if not running - echo "🗄️ Starting database and Redis..." - COMPOSE_FILE="docker-compose.dev.minimal.yml" - - # Stop and remove existing containers to ensure clean start with correct architecture - echo "🧹 Cleaning up existing containers..." - docker stop portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true - docker rm portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true - - # Remove old images to force re-pull with correct architecture - echo "🔄 Removing old images to force re-pull..." - 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..." - docker compose -f $COMPOSE_FILE pull postgres redis - - # Start containers - echo "📦 Starting PostgreSQL and Redis containers..." - docker compose -f $COMPOSE_FILE up -d postgres redis - - # Wait for DB to be ready - echo "⏳ Waiting for database to be ready..." - for i in {1..30}; do - if docker exec portfolio_postgres_dev pg_isready -U portfolio_user -d portfolio_dev >/dev/null 2>&1; then - echo "✅ Database is ready!" - break - fi - echo "⏳ Waiting for database... ($i/30)" - sleep 1 - done - - # Export environment variables - export NODE_ENV=production - export LOG_LEVEL=${LOG_LEVEL:-debug} - export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} - export DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public" - export REDIS_URL="redis://portfolio_redis_dev:6379" - export MY_EMAIL=${MY_EMAIL} - export MY_INFO_EMAIL=${MY_INFO_EMAIL} - export MY_PASSWORD=${MY_PASSWORD} - export MY_INFO_PASSWORD=${MY_INFO_PASSWORD} - export ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} - export ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - export N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} - export N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} - export PORT=${HEALTH_PORT} - - # Stop and remove existing container if it exists (running or stopped) - if [ ! -z "$EXISTING_CONTAINER" ]; then - echo "🛑 Stopping and removing existing container..." - docker stop $EXISTING_CONTAINER 2>/dev/null || true - docker rm $EXISTING_CONTAINER 2>/dev/null || true - echo "✅ Old container removed" - # Wait for Docker to release the port - echo "⏳ Waiting for Docker to release port ${HEALTH_PORT}..." - sleep 3 - fi - - # Check if port is still in use by Docker containers (check all containers, not just running) - PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "") - if [ ! -z "$PORT_CONTAINER" ]; then - echo "⚠️ Port ${HEALTH_PORT} is still in use by container $PORT_CONTAINER" - echo "🛑 Stopping and removing container using port..." - docker stop $PORT_CONTAINER 2>/dev/null || true - docker rm $PORT_CONTAINER 2>/dev/null || true - sleep 3 - fi - - # Also check for any containers with the same name that might be using the port - SAME_NAME_CONTAINER=$(docker ps -a -q -f name=$CONTAINER_NAME | head -1 || echo "") - if [ ! -z "$SAME_NAME_CONTAINER" ] && [ "$SAME_NAME_CONTAINER" != "$EXISTING_CONTAINER" ]; then - echo "⚠️ Found another container with same name: $SAME_NAME_CONTAINER" - docker stop $SAME_NAME_CONTAINER 2>/dev/null || true - docker rm $SAME_NAME_CONTAINER 2>/dev/null || true - sleep 2 - fi - - # Also check if port is in use by another process (non-Docker) - PORT_IN_USE=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || ss -tlnp | grep ":${HEALTH_PORT} " | head -1 || echo "") - if [ ! -z "$PORT_IN_USE" ] && [ -z "$PORT_CONTAINER" ]; then - echo "⚠️ Port ${HEALTH_PORT} is in use by process" - echo "Attempting to free the port..." - # Try to find and kill the process - if command -v lsof >/dev/null 2>&1; then - PID=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "") - if [ ! -z "$PID" ]; then - kill -9 $PID 2>/dev/null || true - sleep 2 - fi - fi - fi - - # Final check: verify port is free and wait if needed - echo "🔍 Verifying port ${HEALTH_PORT} is free..." - MAX_WAIT=10 - WAIT_COUNT=0 - while [ $WAIT_COUNT -lt $MAX_WAIT ]; do - PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "") - if [ -z "$PORT_CHECK" ]; then - # Also check with lsof/ss if available - if command -v lsof >/dev/null 2>&1; then - PORT_CHECK=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "") - elif command -v ss >/dev/null 2>&1; then - PORT_CHECK=$(ss -tlnp | grep ":${HEALTH_PORT} " || echo "") - fi - fi - if [ -z "$PORT_CHECK" ]; then - echo "✅ Port ${HEALTH_PORT} is free!" - break - fi - WAIT_COUNT=$((WAIT_COUNT + 1)) - echo "⏳ Port still in use, waiting... ($WAIT_COUNT/$MAX_WAIT)" - sleep 1 - done - - # If port is still in use, try alternative port - if [ $WAIT_COUNT -ge $MAX_WAIT ]; then - echo "⚠️ Port ${HEALTH_PORT} is still in use after waiting. Trying alternative port..." - HEALTH_PORT="3002" - echo "🔄 Using alternative port: ${HEALTH_PORT}" - # Quick check if alternative port is also in use - ALT_PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "") - if [ ! -z "$ALT_PORT_CHECK" ]; then - echo "❌ Alternative port ${HEALTH_PORT} is also in use!" - echo "Attempting to free alternative port..." - ALT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "") - if [ ! -z "$ALT_CONTAINER" ]; then - docker stop $ALT_CONTAINER 2>/dev/null || true - docker rm $ALT_CONTAINER 2>/dev/null || true - sleep 2 - fi - fi - fi - - # Start new container with updated image - echo "🆕 Starting new dev container..." - docker run -d \ - --name $CONTAINER_NAME \ - --restart unless-stopped \ - --network portfolio_dev \ - -p ${HEALTH_PORT}:3000 \ - -e NODE_ENV=production \ - -e LOG_LEVEL=${LOG_LEVEL:-debug} \ - -e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \ - -e DATABASE_URL=${DATABASE_URL} \ - -e REDIS_URL=${REDIS_URL} \ - -e MY_EMAIL=${MY_EMAIL} \ - -e MY_INFO_EMAIL=${MY_INFO_EMAIL} \ - -e MY_PASSWORD=${MY_PASSWORD} \ - -e MY_INFO_PASSWORD=${MY_INFO_PASSWORD} \ - -e ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} \ - -e ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} \ - -e N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} \ - -e N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} \ - $IMAGE_NAME - - # Connect container to proxy network as well (for external access) - echo "🔗 Connecting container to proxy network..." - docker network connect proxy $CONTAINER_NAME 2>/dev/null || echo "Container might already be connected to proxy network" - - # Wait for new container to be healthy - echo "⏳ Waiting for new container to be healthy..." - HEALTH_CHECK_PASSED=false - for i in {1..60}; do - NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME) - if [ ! -z "$NEW_CONTAINER" ]; then - # Check Docker health status - HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") - if [ "$HEALTH" == "healthy" ]; then - echo "✅ New container is healthy!" - HEALTH_CHECK_PASSED=true - break - fi - # Also check HTTP health endpoint - if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then - echo "✅ New container is responding!" - HEALTH_CHECK_PASSED=true - break - fi - fi - echo "⏳ Waiting... ($i/60)" - sleep 2 - done - - # Verify new container is working - if [ "$HEALTH_CHECK_PASSED" != "true" ]; then - echo "⚠️ New dev container health check failed, but continuing (non-blocking)..." - docker logs $CONTAINER_NAME --tail=50 - fi - - # Remove old container if it exists and is different - if [ ! -z "$OLD_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME) - if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then - echo "🧹 Removing old container..." - docker stop $OLD_CONTAINER 2>/dev/null || true - docker rm $OLD_CONTAINER 2>/dev/null || true - fi - fi - - echo "✅ Dev deployment completed!" - env: - NODE_ENV: production - LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }} - NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }} - DATABASE_URL: postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public - REDIS_URL: redis://portfolio_redis_dev:6379 - MY_EMAIL: ${{ vars.MY_EMAIL }} - MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} - MY_PASSWORD: ${{ secrets.MY_PASSWORD }} - MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} - ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} - ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} - N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} - N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} - - - name: Dev Health Check - run: | - echo "🔍 Running dev health checks..." - for i in {1..20}; do - if curl -f http://localhost:3001/api/health && curl -f http://localhost:3001/ > /dev/null; then - echo "✅ Dev is fully operational!" - exit 0 - fi - echo "⏳ Waiting for dev... ($i/20)" - sleep 3 - done - echo "⚠️ Dev health check failed, but continuing (non-blocking)..." - docker logs portfolio-app-dev --tail=50 - - - name: Cleanup - run: | - echo "🧹 Cleaning up old images..." - docker image prune -f - echo "✅ Cleanup completed" diff --git a/.gitea/workflows/production-deploy.yml b/.gitea/workflows/production-deploy.yml deleted file mode 100644 index 487a518..0000000 --- a/.gitea/workflows/production-deploy.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Production Deployment (Zero Downtime) - -on: - push: - branches: [ production ] - -env: - NODE_VERSION: '25' - DOCKER_IMAGE: portfolio-app - IMAGE_TAG: production - -jobs: - deploy-production: - runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run linting and tests in parallel - run: | - npm run lint & - LINT_PID=$! - npm run test:production & - TEST_PID=$! - wait $LINT_PID $TEST_PID - - - name: Build application - run: npm run build - - - name: Build Docker image - run: | - echo "🏗️ Building production Docker image with BuildKit cache..." - DOCKER_BUILDKIT=1 docker build \ - --cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ - --cache-from ${{ env.DOCKER_IMAGE }}:latest \ - -t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \ - -t ${{ env.DOCKER_IMAGE }}:latest \ - . - echo "✅ Docker image built successfully" - - - name: Zero-Downtime Production Deployment - run: | - echo "🚀 Starting zero-downtime production deployment..." - - COMPOSE_FILE="docker-compose.production.yml" - CONTAINER_NAME="portfolio-app" - HEALTH_PORT="3000" - - # Backup current container ID if running (exact name match to avoid staging) - OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "") - - # Export environment variables for docker-compose - export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}" - export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}" - export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}" - - # Also export other variables that docker-compose needs - 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 }}" - export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}" - 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)}" - docker compose -f $COMPOSE_FILE up -d portfolio - - # Wait for new container to be healthy - echo "⏳ Waiting for new container to be healthy..." - HEALTH_CHECK_PASSED=false - for i in {1..90}; do - # Get the production container ID (exact name match, exclude staging) - # Use compose project to ensure we get the right container - NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$NEW_CONTAINER" ]; then - # Fallback: try exact name match with leading slash - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - if [ ! -z "$NEW_CONTAINER" ]; then - # Verify it's actually the production container by checking compose project label - CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "") - CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "") - if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then - # Check Docker health status first (most reliable) - HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") - if [ "$HEALTH" == "healthy" ]; then - echo "✅ New container is healthy (Docker health check)!" - # Also verify HTTP endpoint from inside container - if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then - echo "✅ Container HTTP endpoint is also responding!" - HEALTH_CHECK_PASSED=true - break - else - echo "⚠️ Docker health check passed, but HTTP endpoint test failed. Continuing..." - fi - fi - # Try HTTP health endpoint from host (may not work if port not mapped yet) - if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then - echo "✅ New container is responding to HTTP health check from host!" - HEALTH_CHECK_PASSED=true - break - fi - # Show container status for debugging - if [ $((i % 10)) -eq 0 ]; then - echo "📊 Container ID: $NEW_CONTAINER" - echo "📊 Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')" - echo "📊 Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')" - echo "📊 Health status: $HEALTH" - echo "📊 Testing from inside container:" - docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed" - docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true - fi - else - echo "⚠️ Found container but it's not from production compose file (skipping): $NEW_CONTAINER" - fi - fi - echo "⏳ Waiting... ($i/90)" - sleep 2 - done - - # Final verification: Check Docker health status (most reliable) - NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$NEW_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - if [ ! -z "$NEW_CONTAINER" ]; then - FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown") - if [ "$FINAL_HEALTH" == "healthy" ]; then - echo "✅ Final verification: Container is healthy!" - HEALTH_CHECK_PASSED=true - fi - fi - - # Verify new container is working - if [ "$HEALTH_CHECK_PASSED" != "true" ]; then - echo "❌ New container failed health check!" - echo "📋 All running containers with 'portfolio' in name:" - docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}" - echo "📋 Production container from compose:" - docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose" - echo "📋 Container logs:" - docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs" - - # Get the correct container ID - NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$NEW_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - - if [ ! -z "$NEW_CONTAINER" ]; then - echo "📋 Container inspect (ID: $NEW_CONTAINER):" - docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found" - echo "📋 Testing health endpoint from inside container:" - docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed" - - # Check Docker health status - if it's healthy, accept it - FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown") - if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then - echo "✅ Docker health check reports healthy - accepting deployment!" - HEALTH_CHECK_PASSED=true - else - echo "❌ Docker health check also reports: $FINAL_HEALTH_CHECK" - exit 1 - fi - else - echo "⚠️ Could not find production container!" - exit 1 - fi - fi - - # Remove old container if it exists and is different - if [ ! -z "$OLD_CONTAINER" ]; then - # Get the new production container ID - NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1) - if [ -z "$NEW_CONTAINER" ]; then - NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then - echo "🧹 Removing old container..." - docker stop $OLD_CONTAINER 2>/dev/null || true - docker rm $OLD_CONTAINER 2>/dev/null || true - fi - fi - - echo "✅ Production deployment completed with zero downtime!" - env: - NODE_ENV: production - LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }} - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }} - MY_EMAIL: ${{ vars.MY_EMAIL }} - MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }} - MY_PASSWORD: ${{ secrets.MY_PASSWORD }} - MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }} - ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }} - ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }} - N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }} - N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }} - N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }} - - - name: Production Health Check - run: | - echo "🔍 Running production health checks..." - COMPOSE_FILE="docker-compose.production.yml" - CONTAINER_NAME="portfolio-app" - - # Get the production container ID - CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1) - if [ -z "$CONTAINER_ID" ]; then - CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$") - fi - - if [ -z "$CONTAINER_ID" ]; then - echo "❌ Production container not found!" - docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}" - exit 1 - fi - - echo "📦 Found container: $CONTAINER_ID" - - # Wait for container to be healthy (using Docker's health check) - HEALTH_CHECK_PASSED=false - for i in {1..30}; do - HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting") - STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown") - - if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then - echo "✅ Container is healthy and running!" - - # Test from inside the container (most reliable) - if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then - echo "✅ Health endpoint responds from inside container!" - HEALTH_CHECK_PASSED=true - break - else - echo "⚠️ Container is healthy but HTTP endpoint test failed. Retrying..." - fi - fi - - if [ $((i % 5)) -eq 0 ]; then - echo "📊 Status: $STATUS, Health: $HEALTH (attempt $i/30)" - fi - - echo "⏳ Waiting for production... ($i/30)" - sleep 2 - done - - if [ "$HEALTH_CHECK_PASSED" != "true" ]; then - echo "❌ Production health check failed!" - echo "📋 Container status:" - docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container" - echo "📋 Container logs:" - docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs" - echo "📋 Testing from inside container:" - docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed" - exit 1 - fi - - echo "✅ Production is fully operational!" - - - name: Cleanup - run: | - echo "🧹 Cleaning up old images..." - docker image prune -f - echo "✅ Cleanup completed" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3975569..312206c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,211 +1,107 @@ # 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. +Dennis Konkol's portfolio (dk0.dev) — Next.js 15, Directus CMS, n8n automation, "Liquid Editorial Bento" 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 +npm run dev:next # Plain Next.js dev server (no Docker) +npm run build # Production build (standalone mode) +npm run lint # ESLint (0 errors required, warnings OK) +npm run lint:fix # Auto-fix lint issues +npm run test # All Jest unit tests +npx jest path/to/test.tsx # Run a single test file +npm run test:watch # Watch mode +npm run test:e2e # Playwright E2E tests +npm run db:generate # Regenerate Prisma client after schema changes ``` -### Build & Deploy -```bash -npm run build # Production build (standalone mode) -npm run start # Start production server -``` +## Architecture -### 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 +### Server/Client Component Split -# 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 -``` +The homepage uses a **server component orchestrator** pattern: -### Linting -```bash -npm run lint # Run ESLint -npm run lint:fix # Auto-fix issues -``` +- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps client sections in `ScrollFadeIn` +- `app/components/Hero.tsx` — **server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server` +- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`, each wrapping their component in a scoped `NextIntlClientProvider` with only the needed translation keys +- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP -### 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 -``` +### SSR Animation Safety -## Architecture Overview +**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if hydration fails. -### 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 +Use `ScrollFadeIn` component instead (`app/components/ScrollFadeIn.tsx`): renders no inline style during SSR (content visible by default), applies opacity+transform only after `hasMounted` check, animates via IntersectionObserver + CSS transitions. -### 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 -``` +Framer Motion `AnimatePresence` is fine for modals/overlays that only render after user interaction. ### 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. +Every data fetch degrades gracefully — the site never crashes: + +1. **Directus CMS** → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself** ### 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`) + +- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout +- Returns `null` on failure (never throws) - 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`) +- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and return `source` field (`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 + +- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image) +- Auth: `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers +- All endpoints have rate limiting and 10s timeout +- Hardcover reading data cached 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` +### i18n -### 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 +- Locales: `en`, `de` — defined in `middleware.ts`, must match `app/[locale]/layout.tsx` +- Client components: `useTranslations("key.path")` from `next-intl` +- Server components: `getTranslations("key.path")` from `next-intl/server` +- Always add keys to both `messages/en.json` and `messages/de.json` -### 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) +### Design System -### 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`) +- Custom Tailwind colors: `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`, `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime` +- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl` +- Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`.`) +- Layout: Bento Grid, no floating overlays +- Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400`) for body text — contrast ratio must be ≥4.5:1 ### 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 +- TypeScript: no `any` — use interfaces from `lib/directus.ts` or `types/` +- Error logging: `console.error` only when `process.env.NODE_ENV === "development"` +- File naming: PascalCase components (`About.tsx`), kebab-case API routes (`book-reviews/`), kebab-case lib utils +- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`) +- Every async component needs a Skeleton loading state + +### Testing + +- Jest with JSDOM; mocks for `window.matchMedia` and `IntersectionObserver` in `jest.setup.ts` +- ESM modules transformed via `transformIgnorePatterns` (react-markdown, remark-*, etc.) +- Server component tests: `const resolved = await Component({ props }); render(resolved)` +- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `` tag ### 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 + +- `output: "standalone"` in `next.config.ts` +- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal on failure), starts server +- CI/CD: `.gitea/workflows/ci.yml` — `test-build` job (all branches), `deploy-dev` (dev only), `deploy-production` (production only) +- Branches: `dev` → testing.dk0.dev, `production` → dk0.dev +- Dev and production share the same PostgreSQL and Redis instances ## Common Tasks ### Adding a CMS-managed section + 1. Define GraphQL query + types in `lib/directus.ts` 2. Create API route in `app/api//route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'` -3. Create component in `app/components/.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` +3. Create component in `app/components/.tsx` with Skeleton loading state +4. Add i18n keys to both `messages/en.json` and `messages/de.json` +5. Create a `Client` wrapper in `ClientWrappers.tsx` with scoped `NextIntlClientProvider` +6. Add to `HomePageServer.tsx` wrapped in `ScrollFadeIn` diff --git a/.gitignore b/.gitignore index 67ac8d9..9626976 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Local tooling -.claude/ +.claude/settings.local.json +.claude/CLAUDE.local.md ._* # dependencies @@ -37,10 +38,6 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* -# Sentry -.sentryclirc -sentry.properties - # vercel .vercel diff --git a/CLAUDE.md b/CLAUDE.md index e34561b..6fb365c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,24 @@ -# CLAUDE.md - Portfolio Project Guide +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview -Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "liquid" design system with soft gradient colors and glassmorphism effects. +Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "Liquid Editorial Bento" design system with soft gradient colors and glassmorphism effects. ## Tech Stack -- **Framework**: Next.js 15 (App Router), TypeScript 5.9 +- **Framework**: Next.js 15 (App Router), TypeScript 5.9, React 19 - **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens - **Theming**: `next-themes` for Dark Mode support (system/light/dark) - **Animations**: Framer Motion 12 -- **3D**: Three.js + React Three Fiber (shader gradient background) +- **3D**: Three.js + React Three Fiber + `@shadergradient/react` (shader gradient background) - **Database**: PostgreSQL via Prisma ORM - **Cache**: Redis (optional) -- **CMS**: Directus (self-hosted, REST/GraphQL, optional) +- **CMS**: Directus (self-hosted, GraphQL only, optional) - **Automation**: n8n webhooks (status, chat, hardcover, image generation) - **i18n**: next-intl (EN + DE), message files in `messages/` -- **Monitoring**: Sentry -- **Deployment**: Docker + Nginx, CI via Gitea Actions +- **Deployment**: Docker + Nginx, CI via Gitea Actions (`output: "standalone"`) ## Commands @@ -26,76 +27,54 @@ npm run dev # Full dev environment (Docker + Next.js) npm run dev:simple # Next.js only (no Docker) npm run dev:next # Plain Next.js dev server npm run build # Production build -npm run lint # ESLint -npm run test # Jest unit tests +npm run lint # ESLint (0 errors required, warnings OK) +npm run lint:fix # Auto-fix lint issues +npm run test # All Jest unit tests +npx jest path/to/test.tsx # Run a single test file +npm run test:watch # Watch mode npm run test:e2e # Playwright E2E tests +npm run db:generate # Regenerate Prisma client after schema changes ``` -## Project Structure +## Architecture -``` -app/ - [locale]/ # i18n routes (en, de) - page.tsx # Homepage (hero, about, projects, contact) - projects/ # Project listing + detail pages - api/ # API routes - book-reviews/ # Book reviews from Directus CMS - content/ # CMS content pages - hobbies/ # Hobbies from Directus - n8n/ # n8n webhook proxies - hardcover/ # Currently reading (Hardcover API via n8n) - status/ # Activity status (coding, music, gaming) - chat/ # AI chatbot - generate-image/ # AI image generation - projects/ # Projects API (PostgreSQL + Directus fallback) - tech-stack/ # Tech stack from Directus - components/ # React components - About.tsx # About section (tech stack, hobbies, books) - CurrentlyReading.tsx # Currently reading widget (n8n/Hardcover) - ReadBooks.tsx # Read books with ratings (Directus CMS) - Projects.tsx # Featured projects section - Hero.tsx # Hero section - Contact.tsx # Contact form -lib/ - directus.ts # Directus GraphQL client (no SDK) - auth.ts # Auth utilities + rate limiting -prisma/ - schema.prisma # Database schema -messages/ - en.json # English translations - de.json # German translations -docs/ # Documentation -``` +### Server/Client Component Split -## Architecture Patterns +The homepage uses a **server component orchestrator** pattern: -### Data Source Hierarchy (Fallback Chain) -1. Directus CMS (if configured via `DIRECTUS_STATIC_TOKEN`) -2. PostgreSQL (for projects, analytics) -3. JSON files (`messages/*.json`) -4. Hardcoded defaults -5. Display key itself as last resort +- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps below-fold sections in `ScrollFadeIn` +- `app/components/Hero.tsx` — **server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server` +- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`; each wraps its component in a scoped `NextIntlClientProvider` with only the needed translation namespace +- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP -All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable. +### SSR Animation Safety + +**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if JS hydration fails or is slow. + +Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) instead: renders no inline style during SSR, applies opacity+transform only after `hasMounted` check via IntersectionObserver + CSS transitions. + +`AnimatePresence` is fine for modals/overlays that only render after user interaction. + +### Data Source Fallback Chain + +Every data fetch degrades gracefully — the site never crashes: + +1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured) → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself** ### CMS Integration (Directus) -- REST/GraphQL calls via `lib/directus.ts` (no Directus SDK) + +- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout +- Returns `null` on failure, never throws - Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews` -- Translations use Directus native translation system (M2O to `languages`) -- Locale mapping: `en` -> `en-US`, `de` -> `de-DE` +- Translations use Directus native M2O system; locale mapping: `en` → `en-US`, `de` → `de-DE` +- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and include a `source` field in the response (`"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 n8n endpoints have rate limiting and timeout protection (10s) -- Hardcover data cached for 5 minutes -### Component Patterns -- Client components with `"use client"` for interactive/data-fetching parts -- `useEffect` for data loading on mount -- `useTranslations` from next-intl for i18n -- Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp` -- Gradient cards with `liquid-*` color tokens and `backdrop-blur-sm` +- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image) +- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers +- All endpoints have rate limiting and 10s timeout +- Hardcover reading data cached 5 minutes ## Design System @@ -103,54 +82,54 @@ Custom Tailwind colors prefixed with `liquid-`: - `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink` - `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime` -Cards use gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`) with `border-2` and `rounded-xl`. +Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`. + +Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`.`). + +Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400` alone) for body text — contrast ratio must be ≥4.5:1. + +## Conventions + +- **TypeScript**: No `any` — use interfaces from `lib/directus.ts` or `types/` +- **Components**: PascalCase files in `app/components/`; every async component needs a Skeleton loading state +- **API routes**: kebab-case directories in `app/api/` +- **i18n**: Always add keys to both `messages/en.json` and `messages/de.json`; `useTranslations()` in client, `getTranslations()` in server components +- **Error logging**: `console.error` only when `process.env.NODE_ENV === "development"` +- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`) +- **No emojis** in code unless explicitly requested + +## Testing Notes + +- Jest with JSDOM; `jest.setup.ts` mocks `window.matchMedia`, `IntersectionObserver`, and `NextResponse` +- ESM modules (react-markdown, remark-*, etc.) handled via `transformIgnorePatterns` in `jest.config.ts` +- Server component tests: `const resolved = await Component({ props }); render(resolved)` +- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `` tag + +## Deployment & CI/CD + +- `output: "standalone"` in `next.config.ts` +- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal), starts server +- CI/CD: `.gitea/workflows/ci.yml` — `test-build` (all branches), `deploy-dev` (dev branch only), `deploy-production` (production branch only) +- **Branches**: `dev` → testing.dk0.dev | `production` → dk0.dev +- Dev and production share the same PostgreSQL and Redis instances ## Key Environment Variables ```bash -# Required for CMS DIRECTUS_URL=https://cms.dk0.dev DIRECTUS_STATIC_TOKEN=... - -# Required for n8n features N8N_WEBHOOK_URL=https://n8n.dk0.dev N8N_SECRET_TOKEN=... N8N_API_KEY=... - -# Database DATABASE_URL=postgresql://... - -# Optional -REDIS_URL=redis://... -SENTRY_DSN=... +REDIS_URL=redis://... # optional ``` -## Conventions +## Adding a CMS-managed Section -- Language: Code in English, user-facing text via i18n (EN + DE) -- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`) -- Components: PascalCase files in `app/components/` -- API routes: kebab-case directories in `app/api/` -- CMS data always has a static fallback - never rely solely on Directus -- Error logging: Only in `development` mode (`process.env.NODE_ENV === "development"`) -- No emojis in code unless explicitly requested - -## Common Tasks - -### Adding a new CMS-managed section -1. Define the GraphQL query + types in `lib/directus.ts` -2. Create an API route in `app/api//route.ts` -3. Create a component in `app/components/.tsx` -4. Add i18n keys to `messages/en.json` and `messages/de.json` -5. Integrate into the parent component (usually `About.tsx`) - -### Adding i18n strings -1. Add keys to `messages/en.json` and `messages/de.json` -2. Access via `useTranslations("key.path")` in client components -3. Or `getTranslations("key.path")` in server components - -### Working with Directus collections -- All queries go through `directusRequest()` in `lib/directus.ts` -- Uses GraphQL endpoint (`/graphql`) -- 2-second timeout, graceful null fallback -- Translations filtered by `languages_code.code` matching Directus locale +1. Define GraphQL query + types in `lib/directus.ts` +2. Create API route `app/api//route.ts` with `runtime='nodejs'`, `dynamic='force-dynamic'`, and `source` field in response +3. Create component `app/components/.tsx` with Skeleton loading state +4. Add i18n keys to both `messages/en.json` and `messages/de.json` +5. Create `Client` wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider` +6. Add to `app/_ui/HomePageServer.tsx` wrapped in `` diff --git a/Dockerfile b/Dockerfile index d0e4cc7..d93f686 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,10 +31,10 @@ RUN npx prisma generate # Copy source code (this invalidates cache when code changes) COPY . . -# Build the application +# Build the application (mount cache for faster rebuilds) ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -RUN npm run build +RUN --mount=type=cache,target=/app/.next/cache npm run build # Verify standalone output was created and show structure for debugging RUN if [ ! -d .next/standalone ]; then \ diff --git a/app/__tests__/components/ActivityFeed.test.tsx b/app/__tests__/components/ActivityFeed.test.tsx index a8f33e3..b606581 100644 --- a/app/__tests__/components/ActivityFeed.test.tsx +++ b/app/__tests__/components/ActivityFeed.test.tsx @@ -58,7 +58,7 @@ describe('ActivityFeed NaN Handling', () => { }); it('should convert gaming.name to string safely', () => { - const validName = String('Test Game' || ''); + const validName = String('Test Game'); expect(validName).toBe('Test Game'); expect(typeof validName).toBe('string'); diff --git a/app/__tests__/components/CurrentlyReading.test.tsx b/app/__tests__/components/CurrentlyReading.test.tsx index 8a1eb1e..aac06bb 100644 --- a/app/__tests__/components/CurrentlyReading.test.tsx +++ b/app/__tests__/components/CurrentlyReading.test.tsx @@ -11,6 +11,7 @@ jest.mock("next-intl", () => ({ // Mock next/image jest.mock("next/image", () => ({ __esModule: true, + // eslint-disable-next-line @next/next/no-img-element default: (props: React.ImgHTMLAttributes) => {props.alt, })); diff --git a/app/__tests__/components/Header.test.tsx b/app/__tests__/components/Header.test.tsx index 64ab5e8..1855e36 100644 --- a/app/__tests__/components/Header.test.tsx +++ b/app/__tests__/components/Header.test.tsx @@ -25,10 +25,10 @@ describe('Header', () => { render(
); expect(screen.getByText('dk')).toBeInTheDocument(); - // Check for navigation links - expect(screen.getByText('Home')).toBeInTheDocument(); - expect(screen.getByText('About')).toBeInTheDocument(); - expect(screen.getByText('Projects')).toBeInTheDocument(); - expect(screen.getByText('Contact')).toBeInTheDocument(); + // Check for navigation links (appear in both desktop and mobile menus) + expect(screen.getAllByText('Home').length).toBeGreaterThan(0); + expect(screen.getAllByText('About').length).toBeGreaterThan(0); + expect(screen.getAllByText('Projects').length).toBeGreaterThan(0); + expect(screen.getAllByText('Contact').length).toBeGreaterThan(0); }); }); diff --git a/app/__tests__/components/Hero.test.tsx b/app/__tests__/components/Hero.test.tsx index 5f540b7..3147380 100644 --- a/app/__tests__/components/Hero.test.tsx +++ b/app/__tests__/components/Hero.test.tsx @@ -1,16 +1,19 @@ import { render, screen } from '@testing-library/react'; import Hero from '@/app/components/Hero'; -// Mock next-intl -jest.mock('next-intl', () => ({ - useLocale: () => 'en', - useTranslations: () => (key: string) => { +// Mock next-intl/server +jest.mock('next-intl/server', () => ({ + getTranslations: () => Promise.resolve((key: string) => { const messages: Record = { + badge: 'Student & Self-Hoster', + line1: 'Building', + line2: 'Stuff.', description: 'Dennis is a student and passionate self-hoster.', - ctaWork: 'View My Work' + ctaWork: 'View My Work', + ctaContact: 'Get in touch', }; return messages[key] || key; - }, + }), })); // Mock next/image @@ -25,6 +28,7 @@ interface ImageProps { jest.mock('next/image', () => ({ __esModule: true, default: ({ src, alt, fill, priority, ...props }: ImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element {alt} ({ })); describe('Hero', () => { - it('renders the hero section correctly', () => { - render(); + it('renders the hero section correctly', async () => { + const HeroResolved = await Hero({ locale: 'en' }); + render(HeroResolved); // Check for the main headlines (defaults in Hero.tsx) expect(screen.getByText('Building')).toBeInTheDocument(); diff --git a/app/__tests__/components/ThemeToggle.test.tsx b/app/__tests__/components/ThemeToggle.test.tsx index 0b16990..ad5a92c 100644 --- a/app/__tests__/components/ThemeToggle.test.tsx +++ b/app/__tests__/components/ThemeToggle.test.tsx @@ -1,12 +1,13 @@ import { render, screen } from "@testing-library/react"; import { ThemeToggle } from "@/app/components/ThemeToggle"; -// Mock next-themes -jest.mock("next-themes", () => ({ +// Mock custom ThemeProvider +jest.mock("@/app/components/ThemeProvider", () => ({ useTheme: () => ({ theme: "light", setTheme: jest.fn(), }), + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); describe("ThemeToggle Component", () => { diff --git a/app/_ui/HomePage.tsx b/app/_ui/HomePage.tsx index 6a95652..2cdde49 100644 --- a/app/_ui/HomePage.tsx +++ b/app/_ui/HomePage.tsx @@ -41,7 +41,7 @@ export default function HomePage() { {/* Spacer to prevent navbar overlap */}
- + {/* Wavy Separator 1 - Hero to About */}
diff --git a/app/_ui/HomePageServer.tsx b/app/_ui/HomePageServer.tsx index d2bc7e4..f29c486 100644 --- a/app/_ui/HomePageServer.tsx +++ b/app/_ui/HomePageServer.tsx @@ -1,14 +1,14 @@ import Header from "../components/Header.server"; +import Hero from "../components/Hero"; +import ScrollFadeIn from "../components/ScrollFadeIn"; import Script from "next/script"; import { - getHeroTranslations, getAboutTranslations, getProjectsTranslations, getContactTranslations, getFooterTranslations, } from "@/lib/translations-loader"; import { - HeroClient, AboutClient, ProjectsClient, ContactClient, @@ -20,9 +20,8 @@ interface HomePageServerProps { } export default async function HomePageServer({ locale }: HomePageServerProps) { - // Parallel laden aller Translations - const [heroT, aboutT, projectsT, contactT, footerT] = await Promise.all([ - getHeroTranslations(locale), + // Parallel laden aller Translations (hero translations handled by Hero server component) + const [aboutT, projectsT, contactT, footerT] = await Promise.all([ getAboutTranslations(locale), getProjectsTranslations(locale), getContactTranslations(locale), @@ -57,7 +56,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) { {/* Spacer to prevent navbar overlap */}
- + {/* Wavy Separator 1 - Hero to About */}
@@ -80,7 +79,9 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
- + + + {/* Wavy Separator 2 - About to Projects */}
@@ -103,7 +104,9 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
- + + + {/* Wavy Separator 3 - Projects to Contact */}
@@ -126,9 +129,13 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
- + + +
- + + +
); } diff --git a/app/api/content/page/route.ts b/app/api/content/page/route.ts index 4e89980..4bdab1c 100644 --- a/app/api/content/page/route.ts +++ b/app/api/content/page/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getContentByKey } from "@/lib/content"; import { getContentPage } from "@/lib/directus"; +import { richTextToSafeHtml } from "@/lib/richtext"; const CACHE_TTL = 300; // 5 minutes @@ -17,6 +18,8 @@ export async function GET(request: NextRequest) { // 1) Try Directus first const directusPage = await getContentPage(key, locale); if (directusPage) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : ""; return NextResponse.json( { content: { @@ -24,6 +27,7 @@ export async function GET(request: NextRequest) { slug: directusPage.slug, locale: directusPage.locale || locale, content: directusPage.content, + html, }, source: "directus", }, diff --git a/app/api/email/respond/route.tsx b/app/api/email/respond/route.tsx index cab9ddc..0ec3595 100644 --- a/app/api/email/respond/route.tsx +++ b/app/api/email/respond/route.tsx @@ -4,15 +4,12 @@ import SMTPTransport from "nodemailer/lib/smtp-transport"; import Mail from "nodemailer/lib/mailer"; import { checkRateLimit, getRateLimitHeaders, getClientIp, requireSessionAuth } from "@/lib/auth"; -const BRAND = { +const B = { siteUrl: "https://dk0.dev", email: "contact@dk0.dev", - bg: "#FDFCF8", - sand: "#F3F1E7", - border: "#E7E5E4", - text: "#292524", - muted: "#78716C", mint: "#A7F3D0", + sky: "#BAE6FD", + purple: "#E9D5FF", red: "#EF4444", }; @@ -26,58 +23,86 @@ function escapeHtml(input: string): string { } function nl2br(input: string): string { - return input.replace(/\r\n|\r|\n/g, "
"); + return escapeHtml(input).replace(/\r\n|\r|\n/g, "
"); } -function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) { +function baseEmail(opts: { title: string; preheader: string; bodyHtml: string }): string { const sentAt = new Date().toLocaleString("de-DE", { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", + year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit", }); - return ` - + return ` - + ${escapeHtml(opts.title)} - -
-
-
-
-
Dennis Konkol
-
- dk0.dev + + +
+
+ + +
+
+
+
+
+
+ ${escapeHtml(opts.preheader)} · ${sentAt} +
+
+ ${escapeHtml(opts.title)} +
+
+
+ dk0.dev +
-
-
${escapeHtml(opts.title)}
-
${escapeHtml(opts.subtitle)} • ${sentAt}
-
-
-
+ +
${opts.bodyHtml}
-
-
- Automatisch generiert von dk0.dev • - ${BRAND.email} + +
+
+ +
+
- - `.trim(); +`; +} + +function messageCard(label: string, html: string, accentColor: string = B.mint): string { + return ` +
+
+ ${label} +
+
${html}
+
`; +} + +function ctaButton(text: string, href: string): string { + return ` +`; } const emailTemplates = { @@ -85,31 +110,16 @@ const emailTemplates = { subject: "Vielen Dank für deine Nachricht! 👋", template: (name: string, originalMessage: string) => { const safeName = escapeHtml(name); - const safeMsg = nl2br(escapeHtml(originalMessage)); return baseEmail({ title: `Danke, ${safeName}!`, - subtitle: "Nachricht erhalten", + preheader: "Nachricht erhalten", bodyHtml: ` -
+

Hey ${safeName},

- danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. -

- -
-
-
Deine Nachricht
-
-
- ${safeMsg} -
-
- - - `.trim(), + danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. 🙌 +

+${messageCard("Deine Nachricht", nl2br(originalMessage))} +${ctaButton("Portfolio ansehen →", B.siteUrl)}`, }); }, }, @@ -117,31 +127,16 @@ const emailTemplates = { subject: "Projekt-Anfrage erhalten! 🚀", template: (name: string, originalMessage: string) => { const safeName = escapeHtml(name); - const safeMsg = nl2br(escapeHtml(originalMessage)); return baseEmail({ title: `Projekt-Anfrage: danke, ${safeName}!`, - subtitle: "Ich melde mich zeitnah", + preheader: "Ich melde mich zeitnah", bodyHtml: ` -
+

Hey ${safeName},

- mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu. -

- -
-
-
Deine Projekt-Nachricht
-
-
- ${safeMsg} -
-
- - - `.trim(), + mega — danke für die Projekt-Anfrage! Ich schaue mir alles an und melde mich bald mit Ideen und Rückfragen. 🚀 +

+${messageCard("Deine Projekt-Anfrage", nl2br(originalMessage), B.sky)} +${ctaButton("Mein Portfolio ansehen →", B.siteUrl)}`, }); }, }, @@ -149,25 +144,15 @@ const emailTemplates = { subject: "Danke für deine Nachricht! ⚡", template: (name: string, originalMessage: string) => { const safeName = escapeHtml(name); - const safeMsg = nl2br(escapeHtml(originalMessage)); return baseEmail({ title: `Danke, ${safeName}!`, - subtitle: "Kurze Bestätigung", + preheader: "Kurze Bestätigung", bodyHtml: ` -
+

Hey ${safeName},

- kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. -

- -
-
-
Deine Nachricht
-
-
- ${safeMsg} -
-
- `.trim(), + kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. ⚡ +

+${messageCard("Deine Nachricht", nl2br(originalMessage))}`, }); }, }, @@ -175,35 +160,19 @@ const emailTemplates = { subject: "Antwort auf deine Nachricht 📧", template: (name: string, originalMessage: string, responseMessage: string) => { const safeName = escapeHtml(name); - const safeOriginal = nl2br(escapeHtml(originalMessage)); - const safeResponse = nl2br(escapeHtml(responseMessage)); return baseEmail({ - title: `Antwort für ${safeName}`, - subtitle: "Neue Nachricht", + title: `Hey ${safeName}!`, + preheader: "Antwort von Dennis", bodyHtml: ` -
+

Hey ${safeName},

- hier ist meine Antwort: + ich habe mir deine Nachricht angeschaut — hier ist meine Antwort: +

+${messageCard("Antwort von Dennis", nl2br(responseMessage), B.mint)} +
+${messageCard("Deine ursprüngliche Nachricht", nl2br(originalMessage), "#2a2a2a")}
- -
-
-
Antwort
-
-
- ${safeResponse} -
-
- -
-
-
Deine ursprüngliche Nachricht
-
-
- ${safeOriginal} -
-
- `.trim(), +${ctaButton("Portfolio ansehen →", B.siteUrl)}`, }); }, }, @@ -231,36 +200,23 @@ export async function POST(request: NextRequest) { originalMessage: string; response?: string; }; - + const { to, name, template, originalMessage, response } = body; - // Validate input if (!to || !name || !template || !originalMessage) { - return NextResponse.json( - { error: "Alle Felder sind erforderlich" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 }); } if (template === "reply" && (!response || !response.trim())) { return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 }); } - // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(to)) { - console.error('❌ Validation failed: Invalid email format'); - return NextResponse.json( - { error: "Ungültige E-Mail-Adresse" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 }); } - // Check if template exists if (!emailTemplates[template]) { - return NextResponse.json( - { error: "Ungültiges Template" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Ungültiges Template" }, { status: 400 }); } const user = process.env.MY_EMAIL ?? ""; @@ -268,10 +224,7 @@ export async function POST(request: NextRequest) { if (!user || !pass) { console.error("❌ Missing email/password environment variables"); - return NextResponse.json( - { error: "E-Mail-Server nicht konfiguriert" }, - { status: 500 }, - ); + return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 }); } const transportOptions: SMTPTransport.Options = { @@ -279,86 +232,50 @@ export async function POST(request: NextRequest) { port: 587, secure: false, requireTLS: true, - auth: { - type: "login", - user, - pass, - }, + auth: { type: "login", user, pass }, connectionTimeout: 30000, greetingTimeout: 30000, socketTimeout: 60000, - tls: { - rejectUnauthorized: false, - ciphers: 'SSLv3' - } + tls: { rejectUnauthorized: false, ciphers: 'SSLv3' }, }; const transport = nodemailer.createTransport(transportOptions); - // Verify transport configuration try { await transport.verify(); - } catch (_verifyError) { - return NextResponse.json( - { error: "E-Mail-Server-Verbindung fehlgeschlagen" }, - { status: 500 }, - ); + } catch { + return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 }); } const selectedTemplate = emailTemplates[template]; - let html: string; - if (template === "reply") { - html = emailTemplates.reply.template(name, originalMessage, response || ""); - } else { - // Narrow the template type so TS knows this is not the 3-arg reply template - const nonReplyTemplate = template as Exclude; - html = emailTemplates[nonReplyTemplate].template(name, originalMessage); - } + const html = template === "reply" + ? emailTemplates.reply.template(name, originalMessage, response || "") + : emailTemplates[template as Exclude].template(name, originalMessage); + const mailOptions: Mail.Options = { from: `"Dennis Konkol" <${user}>`, - to: to, - replyTo: "contact@dk0.dev", + to, + replyTo: B.email, subject: selectedTemplate.subject, html, - text: ` -Hallo ${name}! - -Vielen Dank für deine Nachricht: -${originalMessage} - -${template === "reply" ? `\nAntwort:\n${response || ""}\n` : "\nIch werde mich so schnell wie möglich bei dir melden.\n"} - -Beste Grüße, -Dennis Konkol -Software Engineer & Student -https://dki.one -contact@dk0.dev - `, + text: template === "reply" + ? `Hey ${name}!\n\nAntwort:\n${response}\n\nDeine ursprüngliche Nachricht:\n${originalMessage}\n\n-- Dennis Konkol\n${B.siteUrl}` + : `Hey ${name}!\n\nDanke für deine Nachricht:\n${originalMessage}\n\nIch melde mich bald!\n\n-- Dennis Konkol\n${B.siteUrl}`, }; - const sendMailPromise = () => - new Promise((resolve, reject) => { - transport.sendMail(mailOptions, function (err, info) { - if (!err) { - resolve(info.response); - } else { - reject(err.message); - } - }); + const result = await new Promise((resolve, reject) => { + transport.sendMail(mailOptions, (err, info) => { + if (!err) resolve(info.response); + else reject(err.message); }); - - const result = await sendMailPromise(); - - return NextResponse.json({ - message: "Template-E-Mail erfolgreich gesendet", - template: template, - messageId: result }); - + + return NextResponse.json({ message: "E-Mail erfolgreich gesendet", template, messageId: result }); + } catch (err) { - return NextResponse.json({ - error: "Fehler beim Senden der Template-E-Mail", - details: err instanceof Error ? err.message : 'Unbekannter Fehler' + return NextResponse.json({ + error: "Fehler beim Senden der E-Mail", + details: err instanceof Error ? err.message : 'Unbekannter Fehler', }, { status: 500 }); } } diff --git a/app/api/email/route.tsx b/app/api/email/route.tsx index 006f0b7..1731a06 100644 --- a/app/api/email/route.tsx +++ b/app/api/email/route.tsx @@ -5,12 +5,8 @@ import Mail from "nodemailer/lib/mailer"; import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; import { prisma } from "@/lib/prisma"; -// Sanitize input to prevent XSS function sanitizeInput(input: string, maxLength: number = 10000): string { - return input - .slice(0, maxLength) - .replace(/[<>]/g, '') // Remove potential HTML tags - .trim(); + return input.slice(0, maxLength).replace(/[<>]/g, '').trim(); } function escapeHtml(input: string): string { @@ -22,19 +18,126 @@ function escapeHtml(input: string): string { .replace(/'/g, "'"); } + +function buildNotificationEmail(opts: { + name: string; + email: string; + subject: string; + messageHtml: string; + initial: string; + replyHref: string; + sentAt: string; +}): string { + const { name, email, subject, messageHtml, initial, replyHref, sentAt } = opts; + return ` + + + + + Neue Kontaktanfrage + + + +
+ + +
+ + +
+ +
+ +
+
+
+
+ dk0.dev · Portfolio Kontakt +
+
+ Neue Kontaktanfrage +
+
+ ${escapeHtml(sentAt)} +
+
+
+ dk0.dev +
+
+
+
+ + +
+
+ +
+ ${escapeHtml(initial)} +
+
+
${escapeHtml(name)}
+
${escapeHtml(email)}
+
+
+ + +
+ + + ${escapeHtml(subject)} + +
+
+ + +
+
+ Nachricht +
+
+ ${messageHtml} +
+
+ + +
+ + Direkt antworten → + +
+ Oder einfach auf diese E-Mail antworten — Reply-To ist bereits gesetzt. +
+
+ + +
+
+
+ Automatisch generiert · dk0.dev +
+
+ contact@dk0.dev +
+
+
+ +
+
+ +`; +} + export async function POST(request: NextRequest) { try { - // Rate limiting (defensive: headers may be undefined in tests) const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown'; - if (!checkRateLimit(ip, 5, 60000)) { // 5 emails per minute per IP + if (!checkRateLimit(ip, 5, 60000)) { return NextResponse.json( { error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' }, - { + { status: 429, - headers: { - 'Content-Type': 'application/json', - ...getRateLimitHeaders(ip, 5, 60000) - } + headers: { 'Content-Type': 'application/json', ...getRateLimitHeaders(ip, 5, 60000) }, } ); } @@ -45,49 +148,27 @@ export async function POST(request: NextRequest) { subject: string; message: string; }; - - // Sanitize and validate input + const email = sanitizeInput(body.email || '', 255); const name = sanitizeInput(body.name || '', 100); const subject = sanitizeInput(body.subject || '', 200); const message = sanitizeInput(body.message || '', 5000); - // Email request received - - // Validate input if (!email || !name || !subject || !message) { - console.error('❌ Validation failed: Missing required fields'); - return NextResponse.json( - { error: "Alle Felder sind erforderlich" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 }); } - // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { - console.error('❌ Validation failed: Invalid email format'); - return NextResponse.json( - { error: "Ungültige E-Mail-Adresse" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 }); } - // Validate message length if (message.length < 10) { - console.error('❌ Validation failed: Message too short'); - return NextResponse.json( - { error: "Nachricht muss mindestens 10 Zeichen lang sein" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Nachricht muss mindestens 10 Zeichen lang sein" }, { status: 400 }); } - // Validate field lengths if (name.length > 100 || subject.length > 200 || message.length > 5000) { - return NextResponse.json( - { error: "Eingabe zu lang" }, - { status: 400 }, - ); + return NextResponse.json({ error: "Eingabe zu lang" }, { status: 400 }); } const user = process.env.MY_EMAIL ?? ""; @@ -95,265 +176,98 @@ export async function POST(request: NextRequest) { if (!user || !pass) { console.error("❌ Missing email/password environment variables"); - return NextResponse.json( - { error: "E-Mail-Server nicht konfiguriert" }, - { status: 500 }, - ); + return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 }); } const transportOptions: SMTPTransport.Options = { host: "mail.dk0.dev", port: 587, - secure: false, // Port 587 uses STARTTLS, not SSL/TLS + secure: false, requireTLS: true, - auth: { - type: "login", - user, - pass, - }, - // Increased timeout settings for better reliability - connectionTimeout: 30000, // 30 seconds - greetingTimeout: 30000, // 30 seconds - socketTimeout: 60000, // 60 seconds - // TLS hardening (allow insecure/self-signed only when explicitly enabled) + auth: { type: "login", user, pass }, + connectionTimeout: 30000, + greetingTimeout: 30000, + socketTimeout: 60000, tls: - process.env.SMTP_ALLOW_INSECURE_TLS === "true" || - process.env.SMTP_ALLOW_SELF_SIGNED === "true" - ? { rejectUnauthorized: false } - : { rejectUnauthorized: true, minVersion: "TLSv1.2" }, + process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true" + ? { rejectUnauthorized: false } + : { rejectUnauthorized: true, minVersion: "TLSv1.2" }, }; - // Creating transport with configured options - const transport = nodemailer.createTransport(transportOptions); - // Verify transport configuration with retry logic let verificationAttempts = 0; - const maxVerificationAttempts = 3; - let verificationSuccess = false; - - while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) { + while (verificationAttempts < 3) { try { verificationAttempts++; await transport.verify(); - verificationSuccess = true; + break; } catch (verifyError) { if (process.env.NODE_ENV === 'development') { console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError); } - - if (verificationAttempts >= maxVerificationAttempts) { - if (process.env.NODE_ENV === 'development') { - console.error('All SMTP verification attempts failed'); - } - return NextResponse.json( - { error: "E-Mail-Server-Verbindung fehlgeschlagen" }, - { status: 500 }, - ); + if (verificationAttempts >= 3) { + return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 }); } - - // Wait before retry await new Promise(resolve => setTimeout(resolve, 2000)); } } - const brandUrl = "https://dk0.dev"; const sentAt = new Date().toLocaleString('de-DE', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); - const safeName = escapeHtml(name); - const safeEmail = escapeHtml(email); - const safeSubject = escapeHtml(subject); - const safeMessageHtml = escapeHtml(message).replace(/\n/g, "
"); const initial = (name.trim()[0] || "?").toUpperCase(); const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`; + const messageHtml = escapeHtml(message).replace(/\n/g, "
"); const mailOptions: Mail.Options = { from: `"Portfolio Contact" <${user}>`, - to: "contact@dk0.dev", // Send to your contact email + to: "contact@dk0.dev", replyTo: email, - subject: `Portfolio Kontakt: ${subject}`, - html: ` - - - - - - Neue Kontaktanfrage - Portfolio - - -
-
- -
-
-
- Dennis Konkol -
-
- dk0.dev -
-
-
-
- Neue Kontaktanfrage -
-
- Eingegangen am ${sentAt} -
-
-
-
- - -
- -
-
- ${escapeHtml(initial)} -
-
-
- ${safeName} -
-
- E-Mail: ${safeEmail}
- Betreff: ${safeSubject} -
-
-
- - -
-
-
- Nachricht -
-
-
- ${safeMessageHtml} -
-
- - -
- - Antworten - -
- Oder antworte direkt auf diese E-Mail. -
-
-
- - -
-
- Automatisch generiert von dk0.dev -
-
-
-
- - - `, - text: ` -Neue Kontaktanfrage von deinem Portfolio - -Von: ${name} (${email}) -Betreff: ${subject} - -Nachricht: -${message} - ---- -Diese E-Mail wurde automatisch von dk0.dev generiert. - `, + subject: `📬 Neue Anfrage: ${subject}`, + html: buildNotificationEmail({ name, email, subject, messageHtml, initial, replyHref, sentAt }), + text: `Neue Kontaktanfrage\n\nVon: ${name} (${email})\nBetreff: ${subject}\n\n${message}\n\n---\nEingegangen: ${sentAt}`, }; - // Sending email - - // Email sending with retry logic let sendAttempts = 0; - const maxSendAttempts = 3; - let sendSuccess = false; let result = ''; - while (sendAttempts < maxSendAttempts && !sendSuccess) { + while (sendAttempts < 3) { try { sendAttempts++; - // Email send attempt - - const sendMailPromise = () => - new Promise((resolve, reject) => { - transport.sendMail(mailOptions, function (err, info) { - if (!err) { - // Email sent successfully - resolve(info.response); - } else { - if (process.env.NODE_ENV === 'development') { - console.error("Error sending email:", err); - } - reject(err.message); - } - }); + result = await new Promise((resolve, reject) => { + transport.sendMail(mailOptions, (err, info) => { + if (!err) resolve(info.response); + else { + if (process.env.NODE_ENV === 'development') console.error("Error sending email:", err); + reject(err.message); + } }); - - result = await sendMailPromise(); - sendSuccess = true; - // Email process completed successfully + }); + break; } catch (sendError) { - if (process.env.NODE_ENV === 'development') { - console.error(`Email send attempt ${sendAttempts} failed:`, sendError); + if (sendAttempts >= 3) { + throw new Error(`Failed to send email after 3 attempts: ${sendError}`); } - - if (sendAttempts >= maxSendAttempts) { - if (process.env.NODE_ENV === 'development') { - console.error('All email send attempts failed'); - } - throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`); - } - - // Wait before retry await new Promise(resolve => setTimeout(resolve, 3000)); } } - - // Save contact to database + + // Save to DB try { - await prisma.contact.create({ - data: { - name, - email, - subject, - message, - responded: false - } - }); - // Contact saved to database + await prisma.contact.create({ data: { name, email, subject, message, responded: false } }); } catch (dbError) { - if (process.env.NODE_ENV === 'development') { - console.error('Error saving contact to database:', dbError); - } - // Don't fail the email send if DB save fails + if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError); } - - return NextResponse.json({ - message: "E-Mail erfolgreich gesendet", - messageId: result - }); - + + return NextResponse.json({ message: "E-Mail erfolgreich gesendet", messageId: result }); + } catch (err) { console.error("❌ Unexpected error in email API:", err); - return NextResponse.json({ + return NextResponse.json({ error: "Fehler beim Senden der E-Mail", - details: err instanceof Error ? err.message : 'Unbekannter Fehler' + details: err instanceof Error ? err.message : 'Unbekannter Fehler', }, { status: 500 }); } } diff --git a/app/api/n8n/hardcover/sync-books/route.ts b/app/api/n8n/hardcover/sync-books/route.ts new file mode 100644 index 0000000..9742d14 --- /dev/null +++ b/app/api/n8n/hardcover/sync-books/route.ts @@ -0,0 +1,125 @@ +/** + * POST /api/n8n/hardcover/sync-books + * + * Called by an n8n workflow whenever books are finished in Hardcover. + * Creates new entries in the Directus book_reviews collection. + * Deduplicates by hardcover_id — safe to call repeatedly. + * + * n8n Workflow setup: + * 1. Schedule Trigger (every hour) + * 2. HTTP Request → Hardcover GraphQL (query: me { books_read(limit: 20) { ... } }) + * 3. Code Node → transform to array of HardcoverBook objects + * 4. HTTP Request → POST https://dk0.dev/api/n8n/hardcover/sync-books + * Headers: Authorization: Bearer + * Body: [{ hardcover_id, title, author, image, rating, finished_at }, ...] + * + * Expected body shape (array or single object): + * { + * hardcover_id: string | number // Hardcover book ID, used for deduplication + * title: string + * author: string + * image?: string // Cover image URL + * rating?: number // 1–5 + * finished_at?: string // ISO date string + * } + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getBookReviewByHardcoverId, createBookReview } from '@/lib/directus'; +import { checkRateLimit, getClientIp } from '@/lib/auth'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +interface HardcoverBook { + hardcover_id: string | number; + title: string; + author: string; + image?: string; + rating?: number; + finished_at?: string; +} + +export async function POST(request: NextRequest) { + // Auth: require N8N_SECRET_TOKEN or N8N_API_KEY + const authHeader = request.headers.get('Authorization'); + const apiKeyHeader = request.headers.get('X-API-Key'); + const validToken = process.env.N8N_SECRET_TOKEN; + const validApiKey = process.env.N8N_API_KEY; + + const isAuthenticated = + (validToken && authHeader === `Bearer ${validToken}`) || + (validApiKey && apiKeyHeader === validApiKey); + + if (!isAuthenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Rate limit: max 10 sync requests per minute + const ip = getClientIp(request); + if (!checkRateLimit(ip, 10, 60000, 'hardcover-sync')) { + return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }); + } + + let books: HardcoverBook[]; + try { + const body = await request.json(); + books = Array.isArray(body) ? body : [body]; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (books.length === 0) { + return NextResponse.json({ success: true, created: 0, skipped: 0, errors: 0 }); + } + + const results = { + created: 0, + skipped: 0, + errors: 0, + details: [] as string[], + }; + + for (const book of books) { + if (!book.title || !book.author) { + results.errors++; + results.details.push(`Skipped (missing title/author): ${JSON.stringify(book).slice(0, 80)}`); + continue; + } + + const hardcoverId = String(book.hardcover_id); + + // Deduplication: skip if already in Directus + const existing = await getBookReviewByHardcoverId(hardcoverId); + if (existing) { + results.skipped++; + results.details.push(`Skipped (exists): "${book.title}"`); + continue; + } + + // Create new entry in Directus + const created = await createBookReview({ + hardcover_id: hardcoverId, + book_title: book.title, + book_author: book.author, + book_image: book.image, + rating: book.rating, + finished_at: book.finished_at, + status: 'published', + }); + + if (created) { + results.created++; + results.details.push(`Created: "${book.title}" → id=${created.id}`); + } else { + results.errors++; + results.details.push(`Error creating: "${book.title}" (Directus unavailable or token missing)`); + } + } + + if (process.env.NODE_ENV === 'development') { + console.log('[sync-books]', results); + } + + return NextResponse.json({ success: true, source: 'directus', ...results }); +} diff --git a/app/api/sentry-example-api/route.ts b/app/api/sentry-example-api/route.ts deleted file mode 100644 index 6958bf4..0000000 --- a/app/api/sentry-example-api/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as Sentry from "@sentry/nextjs"; -import { NextResponse } from "next/server"; - -export const dynamic = "force-dynamic"; - -// A faulty API route to test Sentry's error monitoring -export function GET() { - const testError = new Error("Sentry Example API Route Error"); - Sentry.captureException(testError); - return NextResponse.json({ error: "This is a test error from the API route" }, { status: 500 }); -} diff --git a/app/components/About.tsx b/app/components/About.tsx index 11eeba4..a6a523f 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -3,7 +3,6 @@ import { useState, useEffect } from "react"; import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; -import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); import CurrentlyReading from "./CurrentlyReading"; @@ -23,7 +22,7 @@ const iconMap: Record = { const About = () => { const locale = useLocale(); const t = useTranslations("home.about"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); const [techStack, setTechStack] = useState([]); const [hobbies, setHobbies] = useState([]); const [snippets, setSnippets] = useState([]); @@ -44,7 +43,7 @@ const About = () => { ]); const cmsData = await cmsRes.json(); - if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent); + if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string); const techData = await techRes.json(); if (techData?.techStack) setTechStack(techData.techStack); @@ -80,9 +79,6 @@ const About = () => { {/* 1. Large Bio Text */}
@@ -96,8 +92,8 @@ const About = () => {
- ) : cmsDoc ? ( - + ) : cmsHtml ? ( + ) : (

{t("p1")} {t("p2")}

)} @@ -113,9 +109,6 @@ const About = () => { {/* 2. Activity / Status Box */} @@ -130,9 +123,6 @@ const About = () => { {/* 3. AI Chat Box */} @@ -147,9 +137,6 @@ const About = () => { {/* 4. Tech Stack */} @@ -186,9 +173,6 @@ const About = () => {
{/* Library - Larger Span */} @@ -211,9 +195,6 @@ const About = () => {
{/* My Gear (Uses) */} @@ -244,9 +225,6 @@ const About = () => { @@ -282,9 +260,6 @@ const About = () => { {/* 6. Hobbies */} diff --git a/app/components/ActivityFeed.tsx b/app/components/ActivityFeed.tsx index b120581..bb0fb27 100644 --- a/app/components/ActivityFeed.tsx +++ b/app/components/ActivityFeed.tsx @@ -110,7 +110,7 @@ export default function ActivityFeed({ clearInterval(statusInterval); clearInterval(quoteInterval); }; - }, [onActivityChange]); + }, [onActivityChange, allQuotes.length]); if (loading) { return
diff --git a/app/components/ClientProviders.tsx b/app/components/ClientProviders.tsx index 93af8e9..7aaa57a 100644 --- a/app/components/ClientProviders.tsx +++ b/app/components/ClientProviders.tsx @@ -7,15 +7,9 @@ import { ToastProvider } from "@/components/Toast"; import ErrorBoundary from "@/components/ErrorBoundary"; import { ConsentProvider } from "./ConsentProvider"; import { ThemeProvider } from "./ThemeProvider"; -import { motion, AnimatePresence } from "framer-motion"; -const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), { - ssr: false, - loading: () => null, -}); - -const ShaderGradientBackground = dynamic( - () => import("./ShaderGradientBackground"), +const BackgroundBlobs = dynamic( + () => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), { ssr: false, loading: () => null } ); @@ -25,66 +19,19 @@ export default function ClientProviders({ children: React.ReactNode; }) { const [mounted, setMounted] = useState(false); - const [is404Page, setIs404Page] = useState(false); const pathname = usePathname(); useEffect(() => { setMounted(true); - // Check if we're on a 404 page by looking for the data attribute or pathname - const check404 = () => { - try { - if (typeof window !== "undefined" && typeof document !== "undefined") { - const has404Component = document.querySelector('[data-404-page]'); - const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404'))); - setIs404Page(!!has404Component || is404Path); - } - } catch (error) { - // Silently fail - 404 detection is not critical - if (process.env.NODE_ENV === 'development') { - console.warn('Error checking 404 status:', error); - } - } - }; - // Check immediately and after a short delay - try { - check404(); - const timeout = setTimeout(check404, 100); - const interval = setInterval(check404, 500); - return () => { - try { - clearTimeout(timeout); - clearInterval(interval); - } catch { - // Silently fail during cleanup - } - }; - } catch (error) { - // If setup fails, just return empty cleanup - if (process.env.NODE_ENV === 'development') { - console.warn('Error setting up 404 check:', error); - } - return () => {}; - } }, [pathname]); - // Wrap in multiple error boundaries to isolate failures return ( - - - - - {children} - - + + + {children} @@ -99,13 +46,25 @@ function GatedProviders({ }: { children: React.ReactNode; mounted: boolean; - is404Page: boolean; }) { + // Defer animated background blobs until after LCP + const [deferredReady, setDeferredReady] = useState(false); + useEffect(() => { + if (!mounted) return; + let id: ReturnType | number; + if (typeof requestIdleCallback !== "undefined") { + id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 }); + return () => cancelIdleCallback(id as number); + } else { + id = setTimeout(() => setDeferredReady(true), 200); + return () => clearTimeout(id); + } + }, [mounted]); + return ( - {mounted && } - {mounted && } + {deferredReady && }
{children}
diff --git a/app/components/ClientWrappers.tsx b/app/components/ClientWrappers.tsx index a9566e0..70d6b6e 100644 --- a/app/components/ClientWrappers.tsx +++ b/app/components/ClientWrappers.tsx @@ -6,13 +6,15 @@ */ import { NextIntlClientProvider } from 'next-intl'; -import Hero from './Hero'; -import About from './About'; -import Projects from './Projects'; -import Contact from './Contact'; -import Footer from './Footer'; +import dynamic from 'next/dynamic'; + +// Lazy-load below-fold components so their JS doesn't block initial paint / LCP. +// SSR stays on (default) so content is in the initial HTML for SEO. +const About = dynamic(() => import('./About')); +const Projects = dynamic(() => import('./Projects')); +const Contact = dynamic(() => import('./Contact')); +const Footer = dynamic(() => import('./Footer')); import type { - HeroTranslations, AboutTranslations, ProjectsTranslations, ContactTranslations, @@ -27,23 +29,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' { return locale.startsWith('de') ? 'de' : 'en'; } -export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) { - const normalLocale = getNormalizedLocale(locale); - const baseMessages = messageMap[normalLocale]; - - const messages = { - home: { - hero: baseMessages.home.hero - } - }; - - return ( - - - - ); -} - export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) { const normalLocale = getNormalizedLocale(locale); const baseMessages = messageMap[normalLocale]; diff --git a/app/components/ConsentBanner.tsx b/app/components/ConsentBanner.tsx index 22f09dd..1ea4007 100644 --- a/app/components/ConsentBanner.tsx +++ b/app/components/ConsentBanner.tsx @@ -54,8 +54,6 @@ export default function ConsentBanner() { type="button" onClick={() => setMinimized(true)} className="shrink-0 text-xs text-stone-500 hover:text-stone-900 transition-colors" - aria-label="Minimize privacy banner" - title="Minimize" > {s.hide} diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 80af0af..0fed48b 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -5,7 +5,6 @@ import { motion } from "framer-motion"; 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 dynamic from "next/dynamic"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false }); @@ -15,7 +14,7 @@ const Contact = () => { const t = useTranslations("home.contact"); const tForm = useTranslations("home.contact.form"); const tInfo = useTranslations("home.contact.info"); - const [cmsDoc, setCmsDoc] = useState(null); + const [cmsHtml, setCmsHtml] = useState(null); useEffect(() => { (async () => { @@ -25,14 +24,14 @@ const Contact = () => { ); const data = await res.json(); // Only use CMS content if it exists for the active locale. - if (data?.content?.content && data?.content?.locale === locale) { - setCmsDoc(data.content.content as JSONContent); + if (data?.content?.html && data?.content?.locale === locale) { + setCmsHtml(data.content.html as string); } else { - setCmsDoc(null); + setCmsHtml(null); } } catch { // ignore; fallback to static - setCmsDoc(null); + setCmsHtml(null); } })(); }, [locale]); @@ -163,17 +162,14 @@ const Contact = () => { {/* Header Card */}

{t("title")}.

- {cmsDoc ? ( - + {cmsHtml ? ( + ) : (

{t("subtitle")} @@ -184,9 +180,6 @@ const Contact = () => { {/* Info Side (Unified Connect Box) */} @@ -252,9 +245,6 @@ const Contact = () => { {/* Form Side */} diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 0cdfdee..607239c 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; import { Menu, X } from "lucide-react"; import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; @@ -26,11 +25,7 @@ const Header = () => { return ( <>

- +
- +
{/* Mobile Menu Overlay */} - - {isOpen && ( - -
- {navItems.map((item) => ( - setIsOpen(false)} - className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10" - > - {item.name} - - ))} -
-
- )} -
+
+
+ {navItems.map((item) => ( + setIsOpen(false)} + className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10" + > + {item.name} + + ))} +
+
); }; diff --git a/app/components/HeaderClient.tsx b/app/components/HeaderClient.tsx index d8fd09c..55bef47 100644 --- a/app/components/HeaderClient.tsx +++ b/app/components/HeaderClient.tsx @@ -1,13 +1,22 @@ "use client"; import { useState, useEffect } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { Menu, X, Mail } from "lucide-react"; import { SiGithub, SiLinkedin } from "react-icons/si"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import type { NavTranslations } from "@/types/translations"; +// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB) +const MenuIcon = ({ size = 24 }: { size?: number }) => ( + +); +const XIcon = ({ size = 24 }: { size?: number }) => ( + +); +const MailIcon = ({ size = 20 }: { size?: number }) => ( + +); + interface HeaderClientProps { locale: string; translations: NavTranslations; @@ -44,7 +53,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps href: "https://linkedin.com/in/dkonkol", label: "LinkedIn", }, - { icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" }, + { icon: MailIcon, href: "mailto:contact@dk0.dev", label: "Email" }, ]; const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || ""; @@ -55,53 +64,38 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps return ( <> - +
- - +
dk0 - +
- setIsOpen(!isOpen)} - className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors" + className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-all hover:scale-105 active:scale-95" aria-label="Toggle menu" > - {isOpen ? : } - - + {isOpen ? : } + +
- +
- - {isOpen && ( - setIsOpen(false)} - /> - )} - + {/* Mobile menu overlay */} +
setIsOpen(false)} + /> - - {isOpen && ( - -
-
- setIsOpen(false)} - > - dk0 - - -
+ {/* Mobile menu panel */} +
+
+
+ setIsOpen(false)} + > + dk0 + + +
- - - {/* Language Switcher Mobile */} -
- setIsOpen(false)} - className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${ - locale === "en" - ? "bg-stone-900 text-white" - : "bg-stone-100 text-stone-600 hover:bg-stone-200" - }`} - > - EN - - setIsOpen(false)} - className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${ - locale === "de" - ? "bg-stone-900 text-white" - : "bg-stone-100 text-stone-600 hover:bg-stone-200" - }`} - > - DE - -
- -
+ + + ); + })}
- - )} - +
+
+
); } diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index a6a821d..9337184 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -1,135 +1,73 @@ -"use client"; - -import { motion } from "framer-motion"; -import { useLocale, useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import Image from "next/image"; -import { useEffect, useState } from "react"; -const Hero = () => { - const locale = useLocale(); - const t = useTranslations("home.hero"); - const [cmsMessages, setCmsMessages] = useState>({}); +interface HeroProps { + locale: string; +} - useEffect(() => { - (async () => { - try { - const res = await fetch(`/api/messages?locale=${locale}`); - if (res.ok) { - const data = await res.json(); - setCmsMessages(data.messages || {}); - } - } catch {} - })(); - }, [locale]); - - // Helper to get CMS text or fallback - const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback; +export default async function Hero({ locale: _locale }: HeroProps) { + const t = await getTranslations("home.hero"); return ( -
- {/* Liquid Ambient Background */} -
- - +
+ {/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */} +
+
+
-
-
+
+
{/* Left: Text Content */} -
- +
+
- {getLabel("hero.badge", "Student & Self-Hoster")} - + {t("badge")} +

- - {getLabel("hero.line1", "Building")} - - - {getLabel("hero.line2", "Stuff.")} - + + {t("line1")} + + + {t("line2")} +

-

+

{t("description")}

- + {/* Right: The Photo */} - +
- Dennis Konkol + Dennis Konkol
dk0.dev
- +
- +
- +
); -}; - -export default Hero; +} diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx index 1149366..80cecc5 100644 --- a/app/components/Projects.tsx +++ b/app/components/Projects.tsx @@ -74,13 +74,14 @@ const Projects = () => {
)) + ) : projects.length === 0 ? ( +
+ No projects yet. +
) : ( projects.map((project) => ( diff --git a/app/components/ReadBooks.tsx b/app/components/ReadBooks.tsx index 8573f54..d231dda 100644 --- a/app/components/ReadBooks.tsx +++ b/app/components/ReadBooks.tsx @@ -101,7 +101,12 @@ const ReadBooks = () => { } if (reviews.length === 0) { - return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen + return ( +
+ + {t("empty")} +
+ ); } const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW); diff --git a/app/components/RichTextClient.tsx b/app/components/RichTextClient.tsx index 1813c51..e9abf7d 100644 --- a/app/components/RichTextClient.tsx +++ b/app/components/RichTextClient.tsx @@ -1,22 +1,19 @@ "use client"; -import React, { useMemo } from "react"; -import type { JSONContent } from "@tiptap/react"; -import { richTextToSafeHtml } from "@/lib/richtext"; +import React from "react"; +// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml). +// This keeps TipTap/ProseMirror out of the client bundle entirely. export default function RichTextClient({ - doc, + html, className, }: { - doc: JSONContent; + html: string; className?: string; }) { - const html = useMemo(() => richTextToSafeHtml(doc), [doc]); - return (
); diff --git a/app/components/ScrollFadeIn.tsx b/app/components/ScrollFadeIn.tsx new file mode 100644 index 0000000..ae8ae45 --- /dev/null +++ b/app/components/ScrollFadeIn.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useRef, useEffect, useState, type ReactNode } from "react"; + +interface ScrollFadeInProps { + children: ReactNode; + className?: string; + delay?: number; +} + +/** + * Wraps children in a fade-in-up animation triggered by scroll. + * Unlike Framer Motion's initial={{ opacity: 0 }}, this does NOT + * render opacity:0 in SSR HTML — content is visible by default + * and only hidden after JS hydration for the animation effect. + */ +export default function ScrollFadeIn({ children, className = "", delay = 0 }: ScrollFadeInProps) { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const [hasMounted, setHasMounted] = useState(false); + + useEffect(() => { + setHasMounted(true); + const el = ref.current; + if (!el) return; + + // Fallback for browsers without IntersectionObserver + if (typeof IntersectionObserver === "undefined") { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(el); + } + }, + { threshold: 0.1 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/app/components/ShaderGradientBackground.tsx b/app/components/ShaderGradientBackground.tsx index 8f7240c..56e4b88 100644 --- a/app/components/ShaderGradientBackground.tsx +++ b/app/components/ShaderGradientBackground.tsx @@ -1,112 +1,60 @@ -"use client"; - -import React from "react"; -import { ShaderGradientCanvas, ShaderGradient } from "@shadergradient/react"; - -const ShaderGradientBackground = () => { +// Pure CSS gradient background — replaces the Three.js/WebGL shader gradient. +// Server component: no "use client", zero JS bundle cost, renders in initial HTML. +// Visual result is identical since all original spheres had animate="off" (static). +export default function ShaderGradientBackground() { return (