Compare commits

...

258 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 10:52:18 +01:00
Dennis Konkol
e0e0551a83 ci: disable broken auto-deploy workflows, keep gitea CI only
Some checks failed
Gitea CI / test-build (push) Failing after 4m47s
2026-02-24 19:49:13 +00:00
Dennis Konkol
97c600df14 ci: disable GitHub workflow and add Gitea Actions workflow
Some checks failed
Gitea CI / test-build (push) Failing after 4m49s
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m18s
2026-02-24 18:54:31 +00:00
denshooter
6c47cdbd83 Merge branch 'dev' into production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m52s
2026-02-23 23:20:22 +01:00
denshooter
21513b20c4 fix: mark portfolio_net as external to resolve compose label conflict
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 16m31s
Docker Compose refused to adopt the existing portfolio_net network because
it lacked the expected com.docker.compose.network label (created outside
Compose). Mark it as external (matching the dev setup) and pre-create it
in the deployment workflow to ensure it always exists before compose up.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 22:46:57 +01:00
denshooter
bd6007f299 Merge branch 'dev' into production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m0s
2026-02-23 16:03:38 +01:00
denshooter
b162fc8a4f fix: prevent page scroll on load by using container scrollTop instead of scrollIntoView in BentoChat
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 18m31s
2026-02-23 16:03:32 +01:00
denshooter
a5449d2adb fix: use external network for dev compose to avoid label conflicts
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 16m12s
The portfolio_dev network was created manually by the pipeline, causing
docker-compose to fail with label mismatch errors. Now:
- Network is marked as external in compose (compose doesn't try to own it)
- Network creation moved before compose up in the pipeline
- Redundant network check later in pipeline removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:37:35 +01:00
denshooter
a5048634b8 fix: add DB wait-for-ready logic and explicit network names
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m24s
- start-with-migrate.js now waits for the database TCP port to be
  reachable before running Prisma migrations (15 retries, 2s interval).
  Prevents the container from crashing and restarting in a loop when
  postgres is still starting up.
- Add explicit 'name:' to both production and dev compose networks
  to prevent docker-compose project prefix mismatch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:33:27 +01:00
denshooter
b5d64b3f0a fix: set explicit network name to prevent compose prefix mismatch
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Docker Compose prefixes network names with the project name by default.
The app container (started via docker run) was connecting to 'portfolio_dev'
while postgres/redis were on '<project>_portfolio_dev' - different networks.
Setting 'name: portfolio_dev' forces the exact name so all containers
share the same network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:30:11 +01:00
denshooter
d21669ee6d fix: remove unnecessary host port mappings from dev database containers
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Postgres and Redis only need to be reachable via the internal Docker
network (portfolio_dev). Removing host port bindings prevents conflicts
with production or other services and reduces attack surface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:13:16 +01:00
denshooter
3fd7329dc5 fix: use non-conflicting ports for dev database containers
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Change dev PostgreSQL host port from 5432 to 5433 and dev Redis from
6379 to 6380 to avoid conflicts with production containers or other
services on the host. Internal Docker network ports remain unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:11:30 +01:00
denshooter
c449e9e0a8 style: comprehensive mobile responsive overhaul across all sections
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Hero: smoother font scaling (text-[2.75rem] -> sm -> md -> lg), smaller
  photo on mobile, reduced gaps and padding
- About: responsive bento grid with smaller border-radius, compact hobbies
  grid (2-col on mobile), hidden descriptions on small screens
- Projects: wider aspect ratio on mobile (16/10), show tags from sm:,
  smoother title scaling
- Contact: compact form inputs, responsive connect links, smaller gaps
- Footer: reduced top padding and gap on mobile
- HomePage: smaller wave separators (h-12 on mobile)
- 404: compact card padding and button sizing
- ActivityFeed: smaller quote text and min-height on mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:09:45 +01:00
denshooter
689cfa18cf Merge branch 'dev' into production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m19s
2026-02-17 14:47:04 +01:00
denshooter
6fd4756f35 fix: resolve all lint errors, improve type safety, and remove unused code
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 7m26s
Remove unused imports, replace `any` types with proper interfaces in directus.ts
and i18n-loader.ts, exclude scripts/ and coverage/ from ESLint, and fix
unused variable warnings across the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:46:35 +01:00
denshooter
a5dba298f3 feat: major UI/UX overhaul, snippets system, and performance fixes
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m26s
2026-02-16 12:31:40 +01:00
denshooter
6f62b37c3a fix: build and test stability for design overhaul
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m19s
Fixed missing types, import errors, and updated test suites to match the new editorial design. Verified Docker container build.
2026-02-16 02:54:02 +01:00
denshooter
6213a4875a fix: final build and type safety improvements
Fixed map parentheses syntax errors, resolved missing ActivityFeedClient imports, and corrected ActivityFeed prop types for idleQuote support. All systems green.
2026-02-16 02:07:23 +01:00
denshooter
0684231308 feat: implement skeleton loading across all dynamic sections
Added a shimmering Skeleton component. Integrated loading states for Hero, About (Bento Grid), Reading Log, Projects Archive, and Library pages for a premium UX.
2026-02-16 01:43:23 +01:00
denshooter
739ee8a825 fix: restore random nerdy quotes and hide empty project links
Re-implemented random quote rotation in activity feed when idle. Added conditional rendering for project links box to declutter project pages.
2026-02-16 01:39:01 +01:00
denshooter
91eb446ac5 fix: cleanup footer, smart navigation, and projects redesign
Removed aggressive background text in footer. Implemented intelligent back button for projects. Redesigned project archive page. Stabilized idle quote logic in activity feed.
2026-02-16 01:35:35 +01:00
denshooter
7955dfbabb style: unified bento design across all sub-pages
Applied the editorial look to legal notice and privacy policy pages. Created consistent grid-based layouts for easier reading and a premium feel.
2026-02-16 01:30:04 +01:00
denshooter
7603cb6298 feat: fully integrated grid activity and chat
Removed floating overlays. Integrated ActivityFeed and Chat directly into Bento grid cells. Refined layout for maximum clarity and 'Dennis' feel.
2026-02-16 01:21:49 +01:00
denshooter
c3f55c92ed feat: ultimate dynamic editorial overhaul
Automated CMS content seeding, integrated interactive AI Chat into Bento grid, implemented intelligent idle quote logic, and unified editorial styling across all sub-pages.
2026-02-16 01:18:34 +01:00
denshooter
f5081f8765 fix: restore getMessage compatibility and finalize build 2026-02-16 01:13:07 +01:00
denshooter
b6eb24f2e8 feat: complete editorial overhaul with CMS dynamic labels
Centralized UI labels in Directus, integrated AI Chat and Status into Bento grid, created standalone Books page, and redesigned project sub-pages for consistent high-end aesthetic.
2026-02-16 01:11:06 +01:00
denshooter
9fd8c25dc6 feat: authentic Dennis-centric design with hero photo
Moved profile photo to Hero for immediate visibility. Rewrote DE/EN translations to be more personal and focused on self-hosting/student identity. Refined Bento grid for better content flow.
2026-02-16 01:07:48 +01:00
denshooter
cfd2f9f248 style: mega redesign of about section - editorial look
Separated bio and photo into a title row, increased padding to p-12 for all items, and reorganized the bento grid for better flow and spacing.
2026-02-16 01:05:22 +01:00
denshooter
cd3726063c style: refined bento layout and bio structure
Improved About section with side-by-side bio and photo, removed row constraints to prevent text overlap, and added consistent spacing.
2026-02-16 01:03:36 +01:00
denshooter
3cf1b9144d fix: resolve rich text rendering and data mapping issues
Hardened rich text conversion logic to handle malformed Tiptap documents and added null checks for CMS data in About section.
2026-02-16 01:01:27 +01:00
denshooter
18f8fb7407 style: final polish for design overhaul
Fixed all compilation errors, improved responsive layout, added missing icons, and refined animations for a perfect user experience.
2026-02-16 00:54:41 +01:00
denshooter
332adab08c feat: complete design overhaul with bento grid and island nav
Refactored About section to use a responsive Bento Grid layout. Redesigned Hero for stronger visual impact. Implemented floating Island navigation. Updated Project cards for cleaner aesthetic.
2026-02-16 00:48:45 +01:00
denshooter
5347a9ff3b fix: rebalance about layout and fix missing gaming icon
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m23s
2026-02-16 00:45:30 +01:00
denshooter
0b1a45038d fix: cleanup book reviews HTML and improve about layout
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Stripped HTML tags from book reviews, added a grid layout for About section on desktop, and fixed hobby icon mapping.
2026-02-16 00:42:57 +01:00
denshooter
931843a5c6 fix: add missing readBooks translations
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
2026-02-16 00:37:34 +01:00
denshooter
0a0895cf89 feat: add directus setup script for book reviews
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 7m31s
2026-02-15 23:04:18 +01:00
denshooter
5576e41ce0 fix: resolve hydration mismatch and NaN rendering errors
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 9m53s
Added suppressHydrationWarning to html tag and implemented safe date/number handling in project and reading components.
2026-02-15 22:48:47 +01:00
denshooter
cc8fff14d2 fix: resolve project 404s with Directus fallback and upgrade 404 page
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
Merged Directus and PostgreSQL project data, implemented single project fetch from CMS, and modernized the NotFound component with liquid design.
2026-02-15 22:47:25 +01:00
denshooter
6998a0e7a1 feat: secure and document book reviews system
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m3s
Added rate limiting to APIs, cleaned up docs, implemented fallback logic for reviews without text, and added comprehensive n8n guide.
2026-02-15 22:32:49 +01:00
denshooter
0766b46cc8 feat: implement dark mode infrastructure, optimize images, and add SEO structured data
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 10m16s
2026-02-15 22:20:49 +01:00
denshooter
92e5b4936e Claude/add book ratings comments kq0 lx (#66)
* feat: Add book ratings and reviews managed via Directus CMS

Adds a new "Read Books" section below "Currently Reading" in the About
page. Book reviews with star ratings and comments are fetched from a
Directus CMS collection (book_reviews) and displayed with the existing
liquid design system. Includes i18n support (EN/DE), show more/less
toggle, and graceful fallback when the CMS collection does not exist yet.

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34

* chore: Add CLAUDE.md, TODO.md, and fix ReadBooks Tailwind classes

- Add CLAUDE.md with project architecture, conventions, and common tasks
- Add TODO.md with prioritized roadmap (book reviews, CMS, n8n, frontend)
- Fix invalid Tailwind classes in ReadBooks.tsx (h-30 -> h-[7.5rem], w-22 -> w-24)

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-15 22:13:34 +01:00
Claude
99d0d1dba1 chore: Add CLAUDE.md, TODO.md, and fix ReadBooks Tailwind classes
- Add CLAUDE.md with project architecture, conventions, and common tasks
- Add TODO.md with prioritized roadmap (book reviews, CMS, n8n, frontend)
- Fix invalid Tailwind classes in ReadBooks.tsx (h-30 -> h-[7.5rem], w-22 -> w-24)

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34
2026-02-15 22:12:44 +01:00
Claude
032568562c feat: Add book ratings and reviews managed via Directus CMS
Adds a new "Read Books" section below "Currently Reading" in the About
page. Book reviews with star ratings and comments are fetched from a
Directus CMS collection (book_reviews) and displayed with the existing
liquid design system. Includes i18n support (EN/DE), show more/less
toggle, and graceful fallback when the CMS collection does not exist yet.

https://claude.ai/code/session_017E8W9CcHFM5WQVHw74JP34
2026-02-15 22:12:44 +01:00
denshooter
07741761cc Updating (#65)
* Fix ActivityFeed: Remove dynamic import that was causing it to disappear in production

* Fix ActivityFeed hydration error: Move localStorage read to useEffect to prevent server/client mismatch

* Update Node.js version to 25 in Gitea workflows

- Fix EBADENGINE error for camera-controls@3.1.2 which requires Node.js >=22
- Update production-deploy.yml, dev-deploy.yml, and ci-cd-with-gitea-vars.yml.disabled
- Node.js v25 matches local development environment

* Update Dockerfile to use Node.js 25

- Update base image from node:20 to node:25
- Matches Gitea workflow configuration and camera-controls@3.1.2 requirements

* Fix production deployment: Start database dependencies

- Remove --no-deps flag which prevented postgres and redis from starting
- Remove --build flag as image is already built in previous step
- This fixes 'Can't reach database server at postgres:5432' error

* Fix postgres health check in production

- Remove init-db.sql volume mount (not available in CI/CD environment)
- Init script not needed as Prisma handles schema migrations
- Postgres will initialize empty database automatically

* Fix cache permission error in Docker container

- Create cache directories AFTER copying standalone files
- Create both fetch-cache and images subdirectories
- Set proper ownership for nextjs user
- Fixes EACCES permission denied errors for prerender cache

* Fix German jogging fallback text

* Use Directus content in production

* fix: Security vulnerability - block malicious file requests

* fix: Switch projects to Directus, add security fixes and example projects
2026-02-15 22:04:26 +01:00
denshooter
4029cd660d fix: Switch projects to Directus, add security fixes and example projects
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m27s
2026-02-09 16:40:08 +01:00
denshooter
b754af20e6 fix: Security vulnerability - block malicious file requests
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m30s
2026-02-09 16:02:10 +01:00
denshooter
3f31d6f5bb Use Directus content in production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 14m21s
2026-02-05 00:23:11 +01:00
denshooter
8eff9106f5 Fix German jogging fallback text
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
2026-02-05 00:22:26 +01:00
denshooter
af30449071 Fix cache permission error in Docker container
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m19s
- Create cache directories AFTER copying standalone files
- Create both fetch-cache and images subdirectories
- Set proper ownership for nextjs user
- Fixes EACCES permission denied errors for prerender cache
2026-02-03 23:37:37 +01:00
denshooter
98c3ebb96c Fix postgres health check in production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m0s
- Remove init-db.sql volume mount (not available in CI/CD environment)
- Init script not needed as Prisma handles schema migrations
- Postgres will initialize empty database automatically
2026-02-03 23:09:41 +01:00
denshooter
9e2040cefc Fix production deployment: Start database dependencies
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 7m29s
- Remove --no-deps flag which prevented postgres and redis from starting
- Remove --build flag as image is already built in previous step
- This fixes 'Can't reach database server at postgres:5432' error
2026-02-03 22:56:34 +01:00
denshooter
719071345e Update Dockerfile to use Node.js 25
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 13m16s
- Update base image from node:20 to node:25
- Matches Gitea workflow configuration and camera-controls@3.1.2 requirements
2026-02-03 22:38:45 +01:00
denshooter
efafd38b1a Update Node.js version to 25 in Gitea workflows
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 7m46s
- Fix EBADENGINE error for camera-controls@3.1.2 which requires Node.js >=22
- Update production-deploy.yml, dev-deploy.yml, and ci-cd-with-gitea-vars.yml.disabled
- Node.js v25 matches local development environment
2026-02-03 22:29:38 +01:00
denshooter
5c70b26508 Merge dev into production: Add shader gradient background with blur effects and all locale improvements
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 4m46s
2026-02-02 16:19:50 +01:00
denshooter
b7b7ac8207 Fix ShaderGradient component props - remove invalid attributes for production build
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 4m47s
2026-02-02 15:20:24 +01:00
denshooter
4beeca02be Add animated shader gradient background with blur effects
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 4m50s
- Added ShaderGradientBackground component with 3 animated spheres
- Enhanced glass effects with backdrop-blur across all text surfaces
- Improved readability while maintaining colored borders
- Adjusted animation speed and movement parameters for optimal effect
2026-01-27 01:43:17 +01:00
denshooter
13499f7f51 Install React Three Fiber dependencies
- Added @react-three/fiber ^9.5.0
- Added @react-three/drei ^10.7.7
- Required dependencies for ShaderGradient component
2026-01-27 01:26:58 +01:00
denshooter
a814a7cab9 Add shader gradient background with halo effect
- Installed @shadergradient/react, three, and @types/three
- Created ShaderGradientBackground component with custom gradient settings
- Integrated background into root layout with blur effect (120px) and reduced opacity (0.5)
- Adjusted body background to work harmoniously with shader gradient
- Used sphere type with pink/orange gradient colors (#b01040, #b04a17, #e167c5)
- Configured animation, grain, and lighting for atmospheric effect
2026-01-27 01:25:02 +01:00
Copilot
9266b22fb4 Fix NaN rendering error in About section from Directus CMS data (#62)
* Initial plan

* Fix NaN error in gaming and custom activities rendering

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Add unit tests for NaN handling in ActivityFeed

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Refactor: Extract gaming text logic to helper function

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Address code review feedback: improve types and tests

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Fix NaN error in About component (tech stack and hobbies)

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Update TypeScript interfaces to match actual data types

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Improve NaN handling with defensive logging and better null checks

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-23 12:49:47 +01:00
copilot-swe-agent[bot]
a4fa9b42fa Fix JSON parsing for tags and technologies arrays from Directus
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-23 02:17:01 +00:00
copilot-swe-agent[bot]
8f7dc02d4b Fix Directus queries: disable messages collection, fix projects translations, fix featured boolean
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-23 02:13:56 +00:00
copilot-swe-agent[bot]
d6d3386f13 Fix Directus GraphQL queries for content_pages and projects
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-23 02:11:06 +00:00
copilot-swe-agent[bot]
51bad1718c Fix TypeScript errors and create .env file
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-23 02:04:46 +00:00
copilot-swe-agent[bot]
03a2e6156a Initial analysis and planning for portfolio fixes
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-23 02:01:06 +00:00
copilot-swe-agent[bot]
8a1248e3f7 Initial plan 2026-01-23 01:56:45 +00:00
denshooter
e431ff50fc feat: Add Directus setup scripts for collections, fields, and relations
- Created setup-directus-collections.js to automate the creation of tech stack collections, fields, and relations in Directus.
- Created setup-directus-hobbies.js for setting up hobbies collection with translations.
- Created setup-directus-projects.js for establishing projects collection with comprehensive fields and translations.
- Added setup-tech-stack-directus.js to populate tech_stack_items with predefined data.
2026-01-23 02:53:31 +01:00
Copilot
7604e00e0f Refactor locale system: align types with usage, add CMS formatting docs (#59)
* Initial plan

* Initial analysis: understanding locale system issues

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Fix translation types to match actual component usage

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Add comprehensive locale system documentation and fix API route types

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Address code review feedback: improve readability and translate comments to English

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>
2026-01-22 21:25:41 +01:00
denshooter
37a1bc4e18 locale upgrade 2026-01-22 20:56:35 +01:00
denshooter
377631ee50 Copilot/setup sentry nextjs (#58)
* Revise portfolio: warm brown theme, elegant typography, optimized analytics tracking (#55)

* Initial plan

* Update color theme to warm brown and off-white, add elegant fonts, fix analytics tracking

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Fix 404 page integration with warm theme, update admin console colors, fix font loading

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Address code review feedback: fix navigation, add utils, improve tracking

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Fix accessibility and memory leak issues from code review

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* chore: Code cleanup, add Sentry.io monitoring, and documentation (#56)

* Initial plan

* Remove unused code and clean up console statements

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Remove unused components and fix type issues

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Wrap console.warn in development check

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Integrate Sentry.io monitoring and add text editing documentation

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* Initial plan

* feat: Add Sentry configuration files and example pages

- Add sentry.server.config.ts and sentry.edge.config.ts
- Update instrumentation.ts with onRequestError export
- Update instrumentation-client.ts with onRouterTransitionStart export
- Update global-error.tsx to capture exceptions with Sentry
- Create Sentry example page at app/sentry-example-page/page.tsx
- Create Sentry example API route at app/api/sentry-example-api/route.ts

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* feat: Update middleware to allow Sentry example page and fix deprecated API

- Update middleware to exclude /sentry-example-page from locale routing
- Remove deprecated startTransaction API from Sentry example page
- Use consistent DSN configuration with fallback values

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

* refactor: Improve Sentry configuration with environment-based sampling

- Add comments explaining DSN fallback values
- Use environment-based tracesSampleRate (10% in production, 100% in dev)
- Address code review feedback for production-safe configuration

Co-authored-by: denshooter <44590296+denshooter@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-01-22 10:05:43 +01:00
denshooter
33f6d47b3e chore: Update Docker Compose configuration for PostgreSQL security and initialization
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 14m28s
- Removed POSTGRES_HOST_AUTH_METHOD for enhanced security, reverting to default password authentication.
- Eliminated init-db.sql mount, as database initialization is now handled via environment variables, with additional grants managed through Prisma migrations if necessary.
2026-01-15 22:38:10 +01:00
denshooter
019fff1d5b chore: Refactor Gitea deployment workflow for PostgreSQL and Redis management
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 14m57s
- Improved container management by stopping and removing existing containers before starting new ones to ensure a clean environment.
- Added logic to remove old images and pull new ones to match the current architecture.
- Enhanced feedback messages for better clarity during the deployment process.
2026-01-15 22:20:19 +01:00
denshooter
d5475c6443 chore: Remove platform specifications for PostgreSQL and Redis in Docker configuration
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 16m3s
- Simplified the Docker Compose file by removing ARM64 platform specifications for PostgreSQL and Redis services, making it more general-purpose.
2026-01-15 21:48:48 +01:00
denshooter
9f7ecf6a88 chore: Remove exposed ports from PostgreSQL and Redis services in Docker configuration
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 14m59s
- Removed port mappings for PostgreSQL and Redis in the development Docker Compose file to enhance security and avoid potential conflicts.
2026-01-15 21:15:14 +01:00
denshooter
a66da4a59f chore: Enhance Gitea deployment workflow for database and Redis management
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 6m40s
- Added logic to start PostgreSQL and Redis containers if they are not already running.
- Implemented checks to ensure the existence of necessary Docker networks.
- Updated environment variables for database and Redis connections.
- Improved feedback messages for better clarity during the deployment process.
2026-01-15 18:15:18 +01:00
denshooter
5e544afdae chore: Update Docker configuration and Gitea deployment workflow
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 15m36s
- Added a new script for database migration to the Docker image.
- Adjusted Dockerfile to create the scripts directory and copy the migration script with the correct permissions.
- Enhanced the Gitea deployment workflow to ensure the proxy network exists before starting the container.
2026-01-15 17:01:39 +01:00
denshooter
ab02058c9d chore: Improve port management in Gitea deployment workflow
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 14m32s
- Enhanced the deployment script to better handle port conflicts by checking for both Docker containers and non-Docker processes using the specified health port.
- Added logic to wait for the port to be released and attempt to use an alternative port if the original is still in use after a timeout.
- Improved feedback messages for better clarity during the deployment process.
2026-01-15 16:20:08 +01:00
denshooter
38d99a504d chore: Enhance Gitea deployment workflow and add Gitea runner status check script
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 7m46s
- Updated deployment script to check for existing containers and free ports before starting a new container.
- Added a new script to check the status of the Gitea runner, including service checks, running processes, Docker containers, common directories, and network connections.
2026-01-15 16:00:44 +01:00
denshooter
098e7ab6f4 fix: Update Gitea workflows to use ubuntu-latest runner
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Failing after 8m34s
2026-01-15 15:28:09 +01:00
denshooter
24608045fb feat: pushing to both remotes 2026-01-15 15:23:35 +01:00
denshooter
38a98a9ea2 feat: Add Hardcover currently reading integration with i18n support
- Add CurrentlyReading component with beautiful design
- Integrate into About section
- Add German and English translations
- Add n8n API route for Hardcover integration
- Add comprehensive documentation for n8n setup
2026-01-15 14:58:34 +01:00
Cursor Agent
b90a3d589c seo: always serve sitemap.xml even if DB unavailable
Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 10:12:38 +00:00
Cursor Agent
d60f875793 seo: improve metadata base and sitemap resilience
Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 10:11:02 +00:00
Cursor Agent
5b67c457d7 docs: remove duplicated setup guides
Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 10:09:06 +00:00
Cursor Agent
6c60415b8c docs: add consolidated operations guide
Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 10:08:27 +00:00
Cursor Agent
6d5617cd08 fix(ui): reduce framer-motion flicker by narrowing CSS transitions
Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 10:06:23 +00:00
Cursor Agent
a617f6eb92 feat(i18n): centralize more UI texts in messages
Move hardcoded labels/strings in About, Projects, Contact form, Footer and Consent banner into next-intl message files (en/de) so content is maintained in one place.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 10:03:32 +00:00
Cursor Agent
faf41a511b fix: remove invalid iframe allowTransparency prop
Co-authored-by: dennis <dennis@konkol.net>
2026-01-15 09:50:40 +00:00
Cursor Agent
63fc45488a test(e2e): click first visible interactive element
Avoid failing on mobile-only hidden buttons in desktop viewport.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 22:00:39 +00:00
Cursor Agent
721bdfaf53 test(e2e): avoid networkidle in hydration checks
The app performs background polling/analytics, so networkidle can hang. Use domcontentloaded + short waits to reliably catch hydration errors.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 21:59:23 +00:00
Cursor Agent
a56ec97ef9 fix(consent): prevent hydration mismatch + banner flash
Do not decide consent during SSR. Read consent cookie after mount and only render the banner once consent is loaded, avoiding both hydration errors and the brief banner flash on reload.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 21:55:35 +00:00
Cursor Agent
b1a314b8a8 Merge dev_test into dev
Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 21:53:24 +00:00
Cursor Agent
08d24735af Merge branch 'cursor/aktivit-ts-feed-neulade-anzeige-00e6' into dev_test
Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 21:52:32 +00:00
Cursor Agent
fbce838d3f fix(consent): avoid banner flashing on reload
Initialize consent state from cookie synchronously so the banner only shows when no choice was made.

fix(api): fail-soft when DB schema missing

Return null/empty content for CMS endpoints when migrations are not applied instead of crashing with Prisma P2021/P2022.

fix(n8n): parse status response defensively

Handle empty/invalid JSON bodies from n8n to prevent activity feed from getting stuck.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 21:47:31 +00:00
Cursor Agent
73ed89c15a test(e2e): verify activity feed stays visible after reload
Adds a browser-level regression test ensuring the feed renders with the dark container and remains visible after a full reload.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:32:35 +00:00
Cursor Agent
2cd4600063 fix(i18n): load messages by route locale
Import locale JSON messages directly in the [locale] layout to ensure DE pages render DE strings instead of falling back to EN.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:31:27 +00:00
Cursor Agent
f2b3f1edfd fix(i18n): render locale switch as links
Use locale-prefixed <Link> elements for EN/DE so language switching works even when client-side hydration is broken.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:29:55 +00:00
Cursor Agent
411806d5ce fix(i18n): use hard navigation for language switch
Switch locales via window.location.assign to guarantee the URL and messages update even if client-side router navigation is blocked.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:27:20 +00:00
Cursor Agent
b219cc51a0 test(e2e): wait for locale navigation to complete
Locale switch can take longer in dev; wait explicitly for /de URL after click.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:25:48 +00:00
Cursor Agent
dce6b6f567 test(e2e): click locale switcher by aria-label
Use the actual accessible name of the DE button to ensure Playwright clicks the correct element.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:24:00 +00:00
Cursor Agent
c150cd82d9 fix(i18n): make locale switch always navigate
Stop setting NEXT_LOCALE cookie client-side and refresh after navigation, avoiding swallowed cookie errors that can prevent router.push from switching languages.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:21:18 +00:00
Cursor Agent
355c9a13fa test(e2e): force NODE_ENV=development for webServer
Prevents middleware Edge runtime from failing on eval-based dev bundles when NODE_ENV is set to a non-standard value in the environment.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:14:18 +00:00
Cursor Agent
9364b44196 fix(i18n): render consent banner inside NextIntl provider
Move ConsentBanner rendering into the locale layout so next-intl context is always available (prevents missing provider errors).

fix(activity-feed): use dark styling for disabled state

Avoid white disabled cards so the feed never appears as a white/transparent block after reload.

test(e2e): assert nav text changes on locale switch

Strengthen i18n test to verify translated labels.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:09:22 +00:00
Cursor Agent
9082bd256a fix(i18n): update consent banner on locale switch
Use next-intl translations instead of reading NEXT_LOCALE cookie once, so banner text updates immediately when switching languages.

fix(activity-feed): make loading UI match dark theme

Avoid the white loading card on hard reload by using the same dark styling as the normal feed.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 16:00:05 +00:00
Cursor Agent
e115a23485 fix(activity-feed): prevent framer-motion initial-state stuck on reload
Disable initial animations for the feed's fallback UIs so a hard reload can't leave the component stuck small/transparent before any state updates.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 11:11:23 +00:00
Cursor Agent
a19293eda4 fix(activity-feed): avoid hydration mismatch from localStorage
Read tracking preference after mount instead of during initial render to prevent SSR/client divergence that can leave the feed stuck in its initial (small/transparent) styles after page reload.

Co-authored-by: dennis <dennis@konkol.net>
2026-01-14 11:03:59 +00:00
Cursor Agent
1d2c8cee09 Fix: eliminate reload-only hydration mismatches on home
Make HomePage a server component and mount ActivityFeed via a client-only wrapper to avoid Suspense/dynamic boundary differences between SSR and hydration.
2026-01-14 10:45:34 +00:00
Cursor Agent
4f344ff1de Fix: stabilize ActivityFeed UI on reload
Avoid shared dev rate-limit bucket for n8n status and fall back to a stable offline state when the status call fails, preventing the widget from getting stuck in the small translucent loading UI.
2026-01-14 02:47:01 +00:00
Cursor Agent
80077ea1af Merge cursor/umfassende-plattform-berarbeitung-d0f0 into dev_test
Resolve email API TLS/env var merge conflicts and bring latest platform changes into dev_test.
2026-01-14 02:11:17 +00:00
Cursor Agent
abfb710c4b Fix: guard Umami tracking and web vitals performance APIs
Avoid calling undefined umami.track, add safe checks for Performance APIs, and clean up load listeners to prevent .call() crashes in Chrome.
2026-01-14 02:09:22 +00:00
denshooter
c8db7ea78c refactor: rename project from my_portfolio to portfolio 2026-01-14 02:47:57 +01:00
denshooter
7adcda61c9 Merge branches 'dev_test' and 'dev_test' of https://github.com/denshooter/my_portfolio into dev_test 2026-01-14 02:03:01 +01:00
Cursor Agent
ba99889782 Refactor activity feed disabled UI; use plain img for hero image fix
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 16:47:14 +00:00
Cursor Agent
e2616ae0f7 Fix next-intl locale, remove wave animations, and unoptimize hero image
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 16:18:03 +00:00
Cursor Agent
6f1ad8eb4d Refine CMS i18n fallback, refresh UI, add consent minimize, seed i18n content
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 16:10:22 +00:00
Cursor Agent
683735cc63 Add i18n to home sections, improve consent management and middleware asset handling
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 15:57:28 +00:00
Cursor Agent
6a4055500b Exclude static assets (paths with dots) from middleware matcher.
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 15:47:53 +00:00
Cursor Agent
d7dcb17769 Automate dev DB setup with migrations and relax API rate limits in development
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 15:37:22 +00:00
Cursor Agent
423a2af938 Integrate Prisma for content; enhance SEO, i18n, and deployment workflows
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 15:27:35 +00:00
Cursor Agent
f1cc398248 Refactor Docker entrypoint to run Prisma migrations; update schema
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 15:08:23 +00:00
Cursor Agent
80f57184c7 Disable aggressive static asset caching in development to fix HMR.
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 14:51:56 +00:00
Cursor Agent
9839d1ba7c Checkpoint before follow-up message
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 14:49:44 +00:00
Cursor Agent
12245eec8e Refactor for i18n, CMS integration, and project slugs; enhance admin & analytics
Co-authored-by: dennis <dennis@konkol.net>
2026-01-12 14:36:10 +00:00
denshooter
0349c686fa feat(auth): implement session token creation and verification for enhanced security
feat(api): require session authentication for admin routes and improve error handling

fix(api): streamline project image generation by fetching data directly from the database

fix(api): optimize project import/export functionality with session validation and improved error handling

fix(api): enhance analytics dashboard and email manager with session token for admin requests

fix(components): improve loading states and dynamic imports for better user experience

chore(security): update Content Security Policy to avoid unsafe-eval in production

chore(deps): update package.json scripts for consistent environment handling in linting and testing
2026-01-12 00:27:03 +01:00
denshooter
9072faae43 refactor: enhance security and performance in configuration and API routes
- Update Content Security Policy (CSP) in next.config.ts to avoid `unsafe-eval` in production, improving security against XSS attacks.
- Refactor API routes to enforce admin authentication and session validation, ensuring secure access to sensitive endpoints.
- Optimize analytics data retrieval by using database aggregation instead of loading all records into memory, improving performance and reducing memory usage.
- Implement session token creation and verification for better session management and security across the application.
- Enhance error handling and input validation in various API routes to ensure robustness and prevent potential issues.
2026-01-11 22:44:26 +01:00
denshooter
ede591c89e Fix ActivityFeed hydration error: Move localStorage read to useEffect to prevent server/client mismatch
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m10s
2026-01-10 18:28:25 +01:00
denshooter
2defd7a4a9 Fix ActivityFeed: Remove dynamic import that was causing it to disappear in production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
2026-01-10 18:16:01 +01:00
denshooter
9cc03bc475 Prevent white screen: wrap ActivityFeed in error boundary and improve ClientProviders error handling
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m10s
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 11m4s
2026-01-10 17:08:16 +01:00
denshooter
832b468ea7 Fix white screen: add error boundaries and improve error handling in AnalyticsProvider and useWebVitals 2026-01-10 17:07:00 +01:00
denshooter
2a260abe0a Fix ActivityFeed fetch TypeError: add proper error handling and type safety 2026-01-10 17:03:07 +01:00
denshooter
80f2ac61ac Fix type error in KernelPanic404: update currentMusic type to match return type 2026-01-10 16:55:01 +01:00
denshooter
a980ee8fcd Fix runtime errors: PerformanceObserver, localStorage, crypto.randomUUID, hydration issues, and linting errors 2026-01-10 16:54:28 +01:00
denshooter
ca2ed13446 refactor: enhance error handling and performance tracking across components
- Improve localStorage access in ActivityFeed, ChatWidget, and AdminPage with try-catch blocks to handle potential errors gracefully.
- Update performance tracking in AnalyticsProvider and analytics.ts to ensure robust error handling and prevent failures from affecting user experience.
- Refactor Web Vitals tracking to include error handling for observer initialization and data collection.
- Ensure consistent handling of hydration mismatches in components like BackgroundBlobs and ChatWidget to improve rendering reliability.
2026-01-10 16:53:06 +01:00
denshooter
20f0ccb85b refactor: improve 404 page loading experience and styling
- Replace Suspense with useEffect for better control over component mounting.
- Update loading indicators with fixed positioning and enhanced styling for a terminal-like appearance.
- Modify KernelPanic404 component to improve text color handling and ensure proper visibility.
- Introduce checks for 404 page detection based on pathname and data attributes for more accurate rendering.
2026-01-10 03:41:22 +01:00
denshooter
59cc8ee154 refactor: consolidate contact API logic and enhance error handling
- Migrate contact API from route.tsx to route.ts for improved organization.
- Implement filtering, pagination, and rate limiting for GET and POST requests.
- Enhance error handling for database operations, including graceful handling of missing tables.
- Validate input fields and email format in POST requests to ensure data integrity.
2026-01-10 03:13:03 +01:00
denshooter
40d9489395 feat: enhance analytics and performance tracking with real data metrics
- Integrate real page view data from the database for accurate analytics.
- Implement cache-busting for fresh data retrieval in analytics dashboard.
- Calculate and display bounce rate, average session duration, and unique users.
- Refactor performance metrics to ensure only real data is considered.
- Improve user experience with toast notifications for success and error messages.
- Update project editor with undo/redo functionality and enhanced content management.
2026-01-10 03:08:25 +01:00
denshooter
b051d9d2ef style: refine admin dashboard and project management UI with cohesive color palette and improved readability
- Update background colors and text styles for better contrast and legibility.
- Enhance button styles and hover effects for a more modern look.
- Remove unnecessary scaling effects and adjust border styles for consistency.
- Introduce a cohesive design language across components to improve user experience.
2026-01-10 02:40:50 +01:00
denshooter
7d84d35f09 fix: resolve styling issues in admin dashboard and login
Fix login page background color to cream/stone (hide blobs). Remove hover scaling from dashboard stats cards. darkening Admin Panel and Portfolio text.
2026-01-10 02:30:15 +01:00
denshooter
59eb32b45a fix: update admin dashboard styles
Fix white text color on cream background in Project Management section. Remove hover scaling effect from login button.
2026-01-10 02:23:14 +01:00
denshooter
632302fb54 style: enhance project covers with mesh gradients, shine effects, and texture 2026-01-10 01:15:03 +01:00
denshooter
2844b981bb style: modernize project pages with warm organic design and improved readability 2026-01-10 01:13:07 +01:00
denshooter
82b5ca4514 style: modernize logo with sans-serif font and stronger red accent 2026-01-10 01:09:39 +01:00
denshooter
98f1a07b08 style: enhance glassmorphism for projects and chat widget with improved transparency and readability 2026-01-10 01:07:49 +01:00
denshooter
792f0c8aae style: modernize chat widget with glassmorphism and improve mobile layout 2026-01-10 01:05:08 +01:00
denshooter
eaaee17bca style: update chat widget to use warm organic modern color palette 2026-01-10 01:02:58 +01:00
denshooter
ae37294b06 full upgrade 2026-01-10 00:52:08 +01:00
denshooter
b487f4ba75 feat: Add production troubleshooting tools and remove eye icon from ActivityFeed
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m1s
- Add diagnose-production.sh script for comprehensive production diagnostics
- Add fix-production.sh script for automatic production issue resolution
- Add PRODUCTION_TROUBLESHOOTING.md documentation with step-by-step guides
- Remove eye icon from ActivityFeed header (keep only X button for minimize)
- Improve error handling and network connectivity checks
2026-01-09 20:20:08 +01:00
denshooter
37178ce421 fix: Improve production health check to use Docker health status
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 10m54s
- Use Docker health check status instead of host-based curl
- Test from inside container instead of from host
- Better error messages and debugging
- More reliable health check that doesn't depend on port mapping
2026-01-09 20:05:31 +01:00
denshooter
e5233138ab fix: Improve production deployment health check
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 7m13s
- Use docker compose ps to get correct container ID (avoids staging container)
- Verify container is from production compose file before health check
- Accept deployment if Docker health check reports healthy (even if HTTP test fails)
- Better error messages and debugging output
- Fix container ID selection to avoid matching staging containers
2026-01-09 19:53:48 +01:00
denshooter
c989f15cab fix: Add n8n environment variables to production deployment
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 10m24s
- Add N8N_WEBHOOK_URL, N8N_SECRET_TOKEN, N8N_API_KEY to docker-compose.production.yml
- Export environment variables in workflow before docker-compose up
- Improve error logging in chat API for better debugging
- Add better error handling in ChatWidget component
- Create setup guide for n8n chat configuration
2026-01-09 19:40:00 +01:00
denshooter
bd73a77ae3 fix: Reduce component flashing on page load and scroll
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Remove mounted state checks that return null (Hero, About, Projects)
- Reduce animation delays and durations for faster initial render
- Change viewport margins from -100px to -50px for earlier trigger
- Reduce initial animation distances (y: 40 -> 20, y: 30 -> 20)
- Use requestAnimationFrame for Header mount to prevent flash
- Always render components instead of returning null to prevent layout shift
- Optimize Framer Motion transitions for smoother scrolling
2026-01-09 19:36:06 +01:00
denshooter
f63a745221 fix: Improve ChatWidget text visibility and ActivityFeed loading state
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Fix ChatWidget tooltip text being cut off (add z-index and shadow)
- Fix ChatWidget header text overflow with truncate classes
- Add loading state for ActivityFeed so it's visible on production while fetching
- Ensure ActivityFeed shows even when data is loading
2026-01-09 19:32:56 +01:00
denshooter
4e48f55737 docs: Add guide for adding 404 project to production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
2026-01-09 19:30:57 +01:00
denshooter
fadeb9b6b9 feat: Add Kernel Panic 404 page as project and link in footer
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Add 404 link in footer
- Add Kernel Panic 404 as featured project in seed data
- Project includes interactive terminal, Easter eggs, and retro effects
2026-01-09 19:28:45 +01:00
denshooter
947f72ecca feat: Add interactive kernel panic 404 page
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Terminal-style 404 page with boot sequence
- Interactive command line with file system
- Easter eggs: hawkins/011, fsociety, 42, rm -rf /
- CRT monitor effects and visual glitches
- Audio synthesis for key presses and effects
- Full terminal emulator with commands: ls, cd, cat, grep, etc.
2026-01-09 19:26:08 +01:00
denshooter
ab110fd009 fix: Improve health check to use container-internal testing
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Has been cancelled
- Prioritize Docker health status (most reliable)
- Test HTTP endpoint from inside container using docker exec
- Fallback to host-based HTTP test if available
- Better debugging output showing both internal and external tests
- Final verification uses Docker health status as authoritative
2026-01-09 19:18:43 +01:00
denshooter
511c37f104 fix: Install curl in production image and improve health check
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 9m26s
2026-01-09 19:06:58 +01:00
denshooter
3771949ba8 fix: Install curl in production image and improve health check
- Install curl in runner stage for health checks
- Increase health check timeout to 90 attempts (3 minutes)
- Improve health check logic to prioritize HTTP endpoint
- Add better debugging output during health check wait
- Show container status and logs during health check failures
2026-01-09 19:06:07 +01:00
denshooter
1e950823e1 Merge dev into production
Some checks failed
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m19s
- Add activity tracking toggle
- Remove Discord status display
- Fix HTML entity decoding in chat
- Improve n8n chat response parsing
- Add n8n status text guide documentation
2026-01-09 18:46:48 +01:00
denshooter
c5b607a253 fix: Improve n8n chat response parsing
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m6s
- Add comprehensive parsing for various n8n response formats
- Check multiple field names (reply, message, response, text, content, answer, output, result)
- Handle array responses and nested structures (data, json, items)
- Add recursive search for string values in complex objects
- Improve logging to show full n8n response structure
- Only use fallback if truly no response found
2026-01-09 18:11:03 +01:00
denshooter
42a586d183 fix: Properly decode HTML entities in chat messages
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Fix &apos; not being decoded to apostrophe
- Decode HTML entities when loading messages from localStorage
- Improve server-side HTML entity decoding to handle all variations
- Replace hardcoded &apos; in static text with regular apostrophes
- Add support for more HTML entity variations (rsquo, lsquo, etc.)
2026-01-09 18:07:43 +01:00
denshooter
9c24fdf5bd feat: Remove Discord status display from activity feed
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m6s
- Remove status footer section that displayed Discord status
- Status information (online/offline/dnd/away) is no longer shown
- Activity feed now only shows coding, gaming, and music activities
2026-01-09 17:42:05 +01:00
denshooter
d09802ab19 remove: Remove staging banner component
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
2026-01-09 17:36:44 +01:00
denshooter
fc71bc740a docs: Add guide for changing status text in n8n
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
2026-01-09 17:34:18 +01:00
denshooter
242a808590 feat: Add activity tracking toggle and customize status text
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add toggle button to enable/disable activity tracking
- Store tracking preference in localStorage
- Change 'Do Not Disturb' to 'Nicht stören' (German)
- Add better status text translations (online, offline, away)
- Show disabled state when tracking is off
- Stop fetching activity data when tracking is disabled
2026-01-09 17:26:05 +01:00
denshooter
60e69eb37b fix: Remove Traefik labels and add Nginx Proxy Manager support
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Remove Traefik-specific labels (user uses Nginx Proxy Manager)
- Add proper host header handling in middleware for 421 fix
- Create NGINX_PROXY_MANAGER_SETUP.md with complete setup guide
- Fix 421 Misdirected Request by ensuring proper proxy headers
2026-01-09 17:06:08 +01:00
denshooter
d8001fc2c4 fix: Move staging banner to top-left to avoid overlap with activity monitor
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Position banner at top-left instead of bottom-right
- Make banner more compact to reduce visual clutter
- Avoids overlap with ActivityFeed (bottom-right) and ChatWidget (bottom-left)
- Smaller, cleaner design that doesn't interfere with content
2026-01-09 16:04:13 +01:00
denshooter
e8248a6ee1 fix: Ensure staging banner is positioned bottom-right, not top-right
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Add explicit inline styles to override any CSS conflicts
- Set top: auto and left: auto to ensure bottom-right positioning
- Fix banner appearing in wrong location
2026-01-09 15:36:23 +01:00
denshooter
d40fdf6d22 fix: Simplify Gitea variables and improve staging banner design
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m7s
- Remove branch-specific variable names (not needed)
- Each workflow uses its own default based on branch
- Users only need to set general variables, not branch-specific ones
- Redesign staging banner as floating box in bottom-right corner
- Better UX: doesn't block content, dismissible, modern design
2026-01-09 15:14:23 +01:00
denshooter
9486116fd8 feat: Add branch-specific Gitea variables support
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Support NEXT_PUBLIC_BASE_URL_PRODUCTION and NEXT_PUBLIC_BASE_URL_DEV
- Support LOG_LEVEL_PRODUCTION and LOG_LEVEL_DEV
- Fallback to general variables if branch-specific not set
- Add comprehensive GITEA_VARIABLES_SETUP.md guide
- Allows independent configuration for production and dev branches
2026-01-09 15:01:29 +01:00
denshooter
0d44ebee17 feat: Add staging banner to dev/test environment
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add StagingBanner component that displays on dev/staging/test domains
- Shows warning that site is not production-ready
- Automatically detects staging environment via hostname or env vars
- Dismissible banner with smooth animations
- Only shows on dev.dk0.dev or other test domains
2026-01-09 14:54:45 +01:00
denshooter
4184e2fcf0 fix: Decode HTML entities in chat responses and improve n8n error handling
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add HTML entity decoding for chat responses (fixes &apos; display issue)
- Add timeout handling for n8n webhook requests (30s chat, 10s status)
- Improve error logging with detailed error information
- Add N8N_SECRET_TOKEN support for authentication
- Better fallback handling when n8n is unavailable
- Fix server-side HTML entity decoding for chat and status endpoints
2026-01-09 14:52:26 +01:00
denshooter
271703556d fix: Add proxy network to staging container
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Add external proxy network to portfolio-staging service
- Ensures staging container can communicate with reverse proxy
- Matches production configuration
2026-01-09 14:47:37 +01:00
denshooter
fd49095710 feat: Optimize builds, add rollback script, and improve security
All checks were successful
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 13m33s
Build Optimizations:
- Enable Docker BuildKit cache for faster builds (7min → 3-4min)
- Add .dockerignore to reduce build context
- Optimize Dockerfile with better layer caching
- Run linting and tests in parallel
- Skip blocking checks for dev deployments

Rollback Functionality:
- Add rollback.sh script to restore previous versions
- Supports both production and dev environments
- Automatic health checks after rollback

Security Improvements:
- Add authentication to n8n/generate-image endpoint
- Add rate limiting to all n8n endpoints (10-30 req/min)
- Create email obfuscation utilities
- Add ObfuscatedEmail React component
- Document security best practices

Files:
- .dockerignore - Faster builds
- scripts/rollback.sh - Rollback functionality
- lib/email-obfuscate.ts - Email obfuscation utilities
- components/ObfuscatedEmail.tsx - React component
- SECURITY_IMPROVEMENTS.md - Security documentation
2026-01-09 14:30:14 +01:00
denshooter
8c223db2a8 feat: Setup zero-downtime deployments for production and dev branches
Some checks failed
Dev Deployment (Zero Downtime) / deploy-dev (push) Has been cancelled
- Created separate workflows for production and dev deployments
- Production branch → dk0.dev (port 3000)
- Dev branch → dev.dk0.dev (port 3002)
- Zero-downtime deployment pattern (start new, wait for health, remove old)
- Complete isolation between environments (separate containers, databases, networks)
- Cleaned up unused code and files:
  - Removed unused GhostEditor and ResizableGhostEditor components
  - Removed old/unused workflows and markdown files
  - Fixed docker-compose references
- Upgraded dependencies to latest compatible versions
- Fixed TypeScript errors in editor page
- Updated staging to use dev.dk0.dev domain
2026-01-09 14:21:03 +01:00
denshooter
5dcc6ae0a6 fix: Remove newline from quote string literal
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 10m32s
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Successful in 18m39s
Staging Deployment / staging (push) Successful in 16m35s
2026-01-09 12:57:08 +01:00
denshooter
21f0ebaa98 feat: Replace quotes with comprehensive collection of programming quotes 2026-01-09 12:56:54 +01:00
denshooter
db0bf2b0c6 Update staging configuration to avoid port conflicts and enhance deployment scripts
- Changed staging app port from 3001 to 3002 in docker-compose.staging.yml
- Updated PostgreSQL port from 5433 to 5434 and Redis port from 6380 to 6381
- Modified STAGING_SETUP.md to reflect new port configurations
- Adjusted CI/CD workflows to accommodate new staging ports and improve deployment messages
- Added N8N environment variables to staging configuration for better integration
2026-01-09 12:56:53 +01:00
denshooter
de0f3f1e66 fix: Update Dockerfile to correctly copy Next.js 15 standalone output structure
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 8m11s
2026-01-09 03:03:33 +01:00
denshooter
393e8c01cd feat: Enhance Dockerfile with verification for standalone output and update n8n status route to handle missing webhook URL
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 7m56s
2026-01-09 02:36:21 +01:00
denshooter
0e578dd833 feat: Add Dev Branch Testing Guide and CI/CD Pipeline for Staging Deployment
Some checks failed
CI/CD Pipeline (Dev/Staging) / staging (push) Failing after 9m0s
2026-01-09 02:02:08 +01:00
denshooter
5cbe95dc24 Merge branch 'dev_n8n' into dev 2026-01-09 00:24:21 +01:00
denshooter
d0c3049a90 updated the branches for the on push etc. 2026-01-08 19:32:13 +01:00
denshooter
3b2c94c699 chore: Clean up old files 2026-01-08 17:55:29 +01:00
denshooter
cd4d2367ab full upgrade to dev 2026-01-08 16:27:40 +01:00
denshooter
41f404c581 full upgrade to dev 2026-01-08 11:40:42 +01:00
denshooter
7320a0562d full upgrade to dev 2026-01-08 11:31:57 +01:00
denshooter
4bf94007cc full upgrade to dev 2026-01-08 04:27:58 +01:00
denshooter
884d7f984b full upgrade to dev 2026-01-08 04:24:22 +01:00
denshooter
e2c2585468 feat: update Projects component with framer-motion variants and improve animations
refactor: modify layout to use ClientOnly and BackgroundBlobsClient components

fix: correct import statement for ActivityFeed in the main page

fix: enhance sitemap fetching logic with error handling and mock support

refactor: convert BackgroundBlobs to default export for consistency

refactor: simplify ErrorBoundary component and improve error handling UI

chore: update framer-motion to version 12.24.10 in package.json and package-lock.json

test: add minimal Prisma Client mock for testing purposes

feat: create BackgroundBlobsClient for dynamic import of BackgroundBlobs

feat: implement ClientOnly component to handle client-side rendering

feat: add custom error handling components for better user experience
2026-01-08 01:39:17 +01:00
denshooter
c5efd28383 full upgrade 2026-01-07 23:13:25 +01:00
denshooter
4cd3f60c98 feat: Fix hydration errors, navbar overlap, and add AI image generation system
## 🎨 UI/UX Fixes

### Fixed React Hydration Errors
- ActivityFeed: Standardized button styling (gradient → solid)
- ActivityFeed: Unified icon sizes and spacing for SSR/CSR consistency
- ActivityFeed: Added timestamps to chat messages for stable React keys
- About: Fixed duplicate keys in tech stack items (added unique key combinations)
- Projects: Fixed duplicate keys in project tags (combined projectId + tag + index)

### Fixed Layout Issues
- Added spacer after Header component (h-24 md:h-32) to prevent navbar overlap
- Hero section now properly visible below fixed navbar

## 🔧 Backend Improvements

### Database Schema
- Added ActivityStatus model for real-time activity tracking
- Supports: coding activity, music playing, watching, gaming, status/mood
- Single-row design (id=1) with auto-updating timestamps

### API Enhancements
- Fixed n8n status endpoint to handle missing table gracefully
- Added TypeScript interfaces (removed ESLint `any` warnings)
- New API: POST /api/n8n/generate-image for AI image generation
- New API: GET /api/n8n/generate-image?projectId=X for status check

## 🔐 Security & Auth

### Middleware Updates
- Removed premature auth redirect for /manage and /editor routes
- Pages now handle their own authentication (show login forms)
- Security headers still applied to all routes

## 🤖 New Feature: AI Image Generation System

### Complete automated project cover image generation using local Stable Diffusion

**Core Components:**
- Admin UI component (AIImageGenerator.tsx) with preview, generate, and regenerate
- n8n workflow integration for automation
- Context-aware prompt generation based on project metadata
- Support for 10+ project categories with optimized prompts

**Documentation (6 new files):**
- README.md - System overview and features
- SETUP.md - Detailed installation guide (486 lines)
- QUICKSTART.md - 15-minute quick start
- PROMPT_TEMPLATES.md - Category-specific templates (612 lines)
- ENVIRONMENT.md - Environment variables reference
- n8n-workflow-ai-image-generator.json - Ready-to-import workflow

**Database Migration:**
- SQL script: create_activity_status.sql
- Auto-setup script: quick-fix.sh
- Migration guide: prisma/migrations/README.md

**Key Features:**
 Automatic generation on project creation
 Manual regeneration via admin UI
 Category-specific prompts (web, mobile, devops, ai, game, etc.)
 Local Stable Diffusion (no API costs, privacy-first)
 n8n workflow orchestration
 Optimized for web (1024x768)

## 📝 Documentation

- CHANGELOG_DEV.md - Complete changelog with migration guide
- PRE_PUSH_CHECKLIST.md - Pre-push verification checklist
- Comprehensive AI image generation docs

## 🐛 Bug Fixes

1. Fixed "Hydration failed" errors in ActivityFeed
2. Fixed "two children with same key" warnings
3. Fixed navbar overlapping hero section
4. Fixed "relation activity_status does not exist" errors
5. Fixed /manage redirect loop (was going to home page)
6. Fixed TypeScript ESLint errors and warnings
7. Fixed duplicate transition prop in Hero component

## ⚠️ Breaking Changes

None - All changes are backward compatible

## 🔄 Migration Required

Database migration needed for new ActivityStatus table:
```bash
./prisma/migrations/quick-fix.sh
# OR
psql -d portfolio -f prisma/migrations/create_activity_status.sql
```

## 📦 Files Changed

**Modified (7):**
- app/page.tsx
- app/components/About.tsx
- app/components/Projects.tsx
- app/components/ActivityFeed.tsx
- app/components/Hero.tsx
- app/api/n8n/status/route.ts
- middleware.ts
- prisma/schema.prisma

**Created (14):**
- app/api/n8n/generate-image/route.ts
- app/components/admin/AIImageGenerator.tsx
- docs/ai-image-generation/* (6 files)
- prisma/migrations/* (3 files)
- CHANGELOG_DEV.md
- PRE_PUSH_CHECKLIST.md
- COMMIT_MESSAGE.txt

##  Testing

- [x] Build successful: npm run build
- [x] Linting passed: npm run lint (0 errors, 8 warnings)
- [x] No hydration errors in console
- [x] No duplicate key warnings
- [x] /manage accessible (shows login form)
- [x] API endpoints responding correctly
- [x] Navbar no longer overlaps content

## 🚀 Next Steps

1. Test AI image generation with Stable Diffusion setup
2. Test n8n workflow integration
3. Create demo screenshots for new features
4. Update main README.md after merge

---
Co-authored-by: AI Assistant (Claude Sonnet 4.5)
2026-01-07 14:38:57 +01:00
denshooter
26a8610aa7 full upgrade to dev 2026-01-07 14:30:00 +01:00
denshooter
4dc727fcd6 feat: add activity feed and background effects
- Implemented ActivityFeed component to display real-time user activity including coding, music, and chat interactions.
- Added GooFilter and BackgroundBlobs components for enhanced visual effects.
- Updated layout to include new components and ensure proper stacking context.
- Enhanced Tailwind CSS configuration with new color and font settings.
- Created API route to mock activity data from n8n.
- Refactored main page structure to streamline component rendering.
2026-01-06 20:10:00 +01:00
denshooter
e74f85da41 chore(security): update dependencies to fix vulnerabilities
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 11m31s
Test Gitea Variables and Secrets / test-variables (push) Successful in 4s
- Update Next.js to 15.5.7 and React to 19.0.1 (React2Shell fix)
- Update Nodemailer to 7.0.11 (Security fix)
- Update React Markdown and others to resolve all audit issues
- Add SECURITY-UPDATE.md
2025-12-08 16:21:11 +01:00
denshooter
a4af934504 fix: ESLint-Fehler in About-Komponente behoben (Apostrophe escaped)
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 11m12s
Test Gitea Variables and Secrets / test-variables (push) Successful in 4s
2025-11-22 19:25:05 +01:00
denshooter
976a6360fd feat: Website-Rework mit verbessertem Design, Sicherheit und Deployment
- Neue About/Skills-Sektion hinzugefügt
- Verbesserte UI/UX für alle Komponenten
- Enhanced Contact Form mit Validierung
- Verbesserte Security Headers und Middleware
- Sichere Deployment-Skripte (safe-deploy.sh)
- Zero-Downtime Deployment Support
- Verbesserte Docker-Sicherheit
- Umfassende Sicherheits-Dokumentation
- Performance-Optimierungen
- Accessibility-Verbesserungen
2025-11-22 19:24:49 +01:00
denshooter
498bec6edf feat: add quick health fix and test scripts
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 10m29s
Test Gitea Variables and Secrets / test-variables (push) Successful in 3s
- Add quick-health-fix.sh for immediate diagnosis
- Add test-app.sh for comprehensive testing
- Fix localhost connection issues
- Improve health check reliability
2025-10-19 22:39:58 +02:00
denshooter
1ef7f88b0a feat: add diagnostic and health check scripts
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 10m26s
Test Gitea Variables and Secrets / test-variables (push) Successful in 2s
- Add comprehensive health check script
- Add connection issue diagnostic script
- Improve health check reliability
- Better error handling and reporting
2025-10-19 22:02:11 +02:00
denshooter
623411b093 fix: remove unused NextRequest import
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 10m24s
Test Gitea Variables and Secrets / test-variables (push) Successful in 3s
2025-10-19 21:48:49 +02:00
denshooter
45ab058643 fix: resolve linting errors
- Remove unused parameters in logout route
- Remove unused AnimatePresence import
- Remove unused handleLogout function
2025-10-19 21:48:43 +02:00
denshooter
c7bc0ecb1d feat: production deployment configuration for dk0.dev
- Fixed authentication system (removed HTTP Basic Auth popup)
- Added session-based authentication with proper logout
- Updated rate limiting (20 req/s for login, 5 req/m for admin)
- Created production deployment scripts and configs
- Updated nginx configuration for dk0.dev domain
- Added comprehensive production deployment guide
- Fixed logout button functionality
- Optimized for production with proper resource limits
2025-10-19 21:48:26 +02:00
denshooter
138b473418 Fix admin login verification loop
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 10m28s
Test Gitea Variables and Secrets / test-variables (push) Successful in 2s
- Remove checkSession from useEffect dependency array to prevent infinite loop
- Improve session validation logic with better error handling
- Add clear session functionality for debugging
- Add 'Clear Session & Reload' button to help with stuck sessions
- Better session cleanup on validation errors

This should resolve the verification loop issue in the admin login.
2025-10-16 13:30:42 +02:00
denshooter
1f7547a562 Fix health check timing and improve admin login
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 10m26s
Test Gitea Variables and Secrets / test-variables (push) Successful in 3s
- Increase health check wait times in Gitea Actions workflow
- Add additional main page accessibility check with longer timeout
- Remove basic auth middleware to use custom admin login only
- Custom admin login at /manage route provides better UX than browser basic auth

This should resolve the 'Main page is not accessible' issue and provide a nicer admin login experience.
2025-10-15 17:00:06 +02:00
denshooter
1bc50ea7e5 Add portfolio app to proxy network
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 8m42s
Test Gitea Variables and Secrets / test-variables (push) Successful in 3s
- Add proxy network to portfolio service networks
- Define proxy as external network in Docker Compose
- This allows the application to be accessible through the proxy network
- Fixes the 'Main page is not accessible' issue

The portfolio app will now be on both portfolio_net (for internal communication) and proxy (for external access).
2025-10-15 16:38:25 +02:00
denshooter
e75457cf91 Fix Gitea Actions environment variable issues
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 8m46s
Test Gitea Variables and Secrets / test-variables (push) Successful in 3s
- Remove redundant export statements from workflow
- Add default values to Docker Compose environment variables
- Add debugging logs to help diagnose deployment issues
- Ensure environment variables are properly passed to containers

This should resolve the 'variable is not set' warnings and make the main page accessible.
2025-10-15 16:23:05 +02:00
denshooter
a5e5425c33 Fix ESLint error in jest.setup.ts
Some checks failed
CI/CD Pipeline (Using Gitea Variables & Secrets) / production (push) Failing after 8m59s
Test Gitea Variables and Secrets / test-variables (push) Successful in 3s
- Change @ts-ignore to @ts-expect-error as required by ESLint
- Simplify React.act mock to avoid TypeScript complexity
- Ensures linting passes in pre-push checks
2025-10-15 16:10:21 +02:00
denshooter
1901dd44b8 Fix TypeScript error in jest.setup.ts
- Update React.act mock to handle both sync and async callbacks
- Fix type compatibility with React's act function signature
- Ensures proper TypeScript compilation during build
2025-10-15 16:09:48 +02:00
denshooter
aaf80244d7 Update pre-push hook to use production test configuration
- Change from npm run test to npm run test:production
- This ensures the pre-push checks use the same test configuration as CI
- Fixes the test failures that were blocking pushes
2025-10-15 16:09:19 +02:00
denshooter
9b842bd87b Fix ESLint error in jest.setup.ts
- Remove require() import that was causing linting error
- Simplify React act() function mocking for production builds
2025-10-15 16:08:29 +02:00
denshooter
6680d707f1 Refactor CI/CD workflows and configuration files
- Removed unused network configurations from docker-compose.yml.
- Added production-specific Jest configuration in jest.config.production.ts for better test management.
- Updated jest.config.ts to include production build fixes and module resolution improvements.
- Enhanced jest.setup.ts to mock React's act function for production builds.
- Introduced new CI/CD workflows for Gitea, focusing on reliability and zero downtime deployments.
- Added scripts for debugging Gitea Actions and verifying environment variables.

These changes streamline the CI/CD process and improve testing capabilities.
2025-10-15 16:07:35 +02:00
denshooter
9f305d3e78 Fix environment variables in deployment scripts
Some checks failed
CI/CD Pipeline (Reliable & Simple) / production (push) Failing after 8m25s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m28s
- Export all environment variables before deployment to ensure they're available to child processes
- Add logging to show which variables are configured (without revealing secrets)
- This fixes docker-compose warnings about missing environment variables
- Ensures both docker run and docker-compose scenarios work correctly

Updated scripts:
- scripts/gitea-deploy.sh
- scripts/gitea-deploy-simple.sh
2025-10-15 15:39:00 +02:00
denshooter
04522d3093 Fix CI/CD root user error in deployment scripts
Some checks failed
CI/CD Pipeline (Reliable & Simple) / production (push) Failing after 9m5s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m29s
- Modify root checks to allow running as root in CI environments
- Add conditional check: only prevent root when not in CI (CI env var not set)
- Updated scripts:
  - scripts/gitea-deploy.sh
  - scripts/gitea-deploy-simple.sh
  - scripts/deploy.sh
  - scripts/auto-deploy.sh
  - scripts/setup-gitea-runner.sh

This fixes the 'This script should not be run as root' error in Gitea Actions
where containers run as root by default.
2025-10-15 15:14:22 +02:00
Denshooter
8d65e2d7c3 Merge branch 'production' of https://git.dk0.dev/denshooter/portfolio into production
Some checks failed
CI/CD Pipeline (Reliable & Simple) / production (push) Failing after 8m19s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m15s
2025-09-16 00:30:20 +02:00
Denshooter
dca8cb8973 Fix CI/CD: Switch from Gitea Actions to Woodpecker CI workflow
- Disabled ci-cd-fast.yml (Gitea Actions syntax)
- Added ci-cd-woodpecker.yml (proper Woodpecker CI syntax)
- Fixed environment variable and secret access
- Should resolve deployment issues with missing variables
2025-09-16 00:29:23 +02:00
denshooter
f66844870a Fix nginx startup script syntax errors
Some checks failed
CI/CD Pipeline (Fast) / production (push) Successful in 6m41s
CI/CD Pipeline (Reliable & Simple) / production (push) Failing after 8m42s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m15s
- Simplify nginx startup command to avoid YAML syntax issues
- Remove complex fallback configuration that was causing shell errors
- nginx now starts successfully and serves the application correctly

Tested locally: nginx responds to /health and / endpoints properly.
2025-09-16 00:03:01 +02:00
denshooter
6be2feb8dd 🔧 Add PowerShell script for Bitwarden .env synchronization
Some checks failed
CI/CD Pipeline (Reliable & Simple) / production (push) Has been cancelled
CI/CD Pipeline (Simple & Reliable) / production (push) Has been cancelled
CI/CD Pipeline (Fast) / production (push) Has been cancelled
- Introduced `sync-env.ps1` to facilitate the synchronization of environment variables from Bitwarden on Windows.
- Implemented checks for Bitwarden CLI installation and authentication status.
- Added functionality to fetch environment variables from a specified Bitwarden item and create/update a `.env` file.
- Enhanced user feedback with clear error messages and success confirmations.

 This script streamlines the management of environment variables by integrating with Bitwarden, ensuring secure and efficient updates.
2025-09-16 00:00:07 +02:00
denshooter
efda383bd8 Fix nginx configuration syntax errors
Some checks failed
CI/CD Pipeline (Fast) / production (push) Successful in 6m44s
CI/CD Pipeline (Simple & Reliable) / production (push) Has been cancelled
CI/CD Pipeline (Reliable & Simple) / production (push) Has been cancelled
- Move proxy_set_header directives inside location blocks
- Add DNS resolver for dynamic upstream resolution
- Improve fallback configuration in docker-compose
- Add config validation before starting nginx

This should resolve the nginx startup failures.
2025-09-15 23:51:59 +02:00
denshooter
9c6b313435 Fix nginx configuration conflict in docker-compose
Some checks failed
CI/CD Pipeline (Reliable & Simple) / production (push) Has been cancelled
CI/CD Pipeline (Simple & Reliable) / production (push) Has been cancelled
CI/CD Pipeline (Fast) / production (push) Has been cancelled
- Remove default nginx configuration files to prevent conflicts
- Add command to clear /etc/nginx/conf.d/* before starting nginx
- This fixes the 'events directive not allowed here' error
2025-09-15 23:48:46 +02:00
denshooter
b06151739f 🔧 Add Bitwarden .env sync script for environment variable management
Some checks failed
CI/CD Pipeline (Reliable & Simple) / production (push) Has been cancelled
CI/CD Pipeline (Simple & Reliable) / production (push) Has been cancelled
CI/CD Pipeline (Fast) / production (push) Has been cancelled
- Introduced `sync-env.sh` to automate the synchronization of environment variables from Bitwarden.
- Implemented authentication with Bitwarden CLI, including session management and error handling.
- Added functionality to fetch environment variables from Bitwarden items and create/update a `.env` file in the specified target directory.
- Included logging for debugging and operational transparency.

 This script enhances the management of environment variables by integrating with Bitwarden, ensuring secure and efficient updates.
2025-09-15 23:42:52 +02:00
denshooter
ed95163f55 🔧 Improve health check logic in Gitea workflows
Some checks failed
CI/CD Pipeline (Fast) / production (push) Successful in 7m47s
CI/CD Pipeline (Reliable & Simple) / production (push) Failing after 8m46s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m17s
- Enhanced health check mechanisms in `ci-cd-fast.yml` and `ci-cd-zero-downtime-fixed.yml` to utilize `docker exec` for internal checks, addressing issues with direct port access.
- Updated health check logic to provide better error messages and fallback methods, ensuring more reliable deployment verification.
- Documented changes in `DEPLOYMENT-FIXES.md` to reflect improvements in health check processes.

 These updates enhance the reliability of health checks during deployments and improve debugging capabilities.
2025-09-15 14:15:49 +02:00
denshooter
fc3f9ebf12 🔧 Enhance Gitea deployment workflows and fix nginx configuration
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 6m12s
CI/CD Pipeline (Reliable & Simple) / production (push) Failing after 8m16s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m19s
- Added new CI/CD workflow `ci-cd-reliable.yml` for reliable deployments with database support.
- Created `docker-compose.zero-downtime-fixed.yml` to address nginx configuration issues for zero-downtime deployments.
- Improved existing workflows to check for nginx configuration file and create a fallback if missing.
- Updated `DEPLOYMENT-FIXES.md` to document new workflows and fixes.

 These changes improve deployment reliability and ensure proper nginx configuration for seamless updates.
2025-09-14 19:11:37 +02:00
denshooter
ca2cbc2c92 🔧 Implement deployment fixes and enhance Gitea workflows
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 6m6s
CI/CD Pipeline (Simple & Reliable) / production (push) Failing after 6m15s
CI/CD Pipeline (Zero Downtime - Fixed) / production (push) Failing after 7m59s
- Created `DEPLOYMENT-FIXES.md` to document issues and solutions for Gitea Actions.
- Fixed Dockerfile path for standalone build.
- Enhanced `gitea-deploy.sh` with improved environment variable handling and extended health check timeouts.
- Introduced `gitea-deploy-simple.sh` for simplified deployments without database dependencies.
- Updated Next.js configuration to resolve build issues.
- Improved health check logic and error handling across all Gitea workflows.

 These changes enhance deployment reliability and provide better debugging information.
2025-09-13 23:48:50 +02:00
denshooter
cc5009a0d6 🔧 Update Next.js configuration and enhance Gitea deployment script
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 5m1s
CI/CD Pipeline (Zero Downtime - Fixed) / production (push) Has been cancelled
- Added serverRuntimeConfig to next.config.ts for improved server-side configuration.
- Updated gitea-deploy.sh to include additional environment variables for deployment.
- Increased sleep duration and health check timeout for better container readiness verification.
- Implemented checks to ensure the container is running during health checks and logs container status if it fails.

 Enhancements improve deployment reliability and server configuration management.
2025-09-13 23:37:53 +02:00
denshooter
116dac89b3 🔧 Fix Gitea Actions for zero-downtime deployment
- Create new ci-cd-zero-downtime-fixed.yml workflow
- Disable old workflows that try to access port 3000 directly
- New workflow uses docker-compose.zero-downtime.yml
- Health checks now use nginx on port 80 instead of direct port 3000
- Fixes the 'Connection refused' errors in Gitea Actions

 Actions now properly work with zero-downtime nginx setup
2025-09-13 23:20:51 +02:00
denshooter
3dbe80edcc 🔧 Fix zero-downtime deployment issues
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 5m10s
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m53s
CI/CD Pipeline (Simple) / production (push) Failing after 9m14s
- Fix Dockerfile standalone build path from /app/.next/standalone/gitea/portfolio to /app/.next/standalone/app
- Fix nginx configuration by removing conflicting server blocks
- Consolidate health check and main proxy into single server block
- Ensure proper load balancing between portfolio-app-1 and portfolio-app-2

 Deployment now working successfully with:
- Application running on both instances (healthy)
- Database and Redis running (healthy)
- Nginx load balancer working
- Health endpoints accessible
- Main portfolio site accessible at http://localhost/
2025-09-13 22:31:39 +02:00
denshooter
bdc38d8b57 Fix container conflicts and environment variables properly
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 3m40s
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m57s
CI/CD Pipeline (Simple) / production (push) Failing after 9m21s
- Add aggressive container cleanup including specific problematic container ID
- Export environment variables before docker compose commands
- Remove all containers with 'portfolio' in name to prevent conflicts
- Fix both rolling update and fresh deployment cases
- Tested locally and verified working
- Environment variables now properly passed to docker compose
2025-09-13 21:12:32 +02:00
denshooter
6338a34612 Fix container conflicts and environment variables
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 3m40s
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m53s
CI/CD Pipeline (Simple) / production (push) Has been cancelled
- Add comprehensive container cleanup before starting services
- Pass environment variables to docker compose commands
- Fix container name conflicts by removing all existing containers first
- Add local test script to verify deployment process
- Ensure clean environment for zero-downtime deployments
2025-09-13 20:59:01 +02:00
denshooter
58dd60ea64 Force remove problematic container and improve Node.js setup
Some checks failed
CI/CD Pipeline (Fast) / production (push) Failing after 4m18s
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m51s
CI/CD Pipeline (Simple) / production (push) Failing after 7m53s
- Add specific removal of problematic container afa9a70588844b06e17d5e0527119d589a7a3fde8a17608447cf7d8d448cf261
- Force remove portfolio-app-new container before deployment
- Add container listing for debugging after cleanup
- Upgrade setup-node to v4 for better performance
- Add cache-dependency-path for more efficient caching
- Create fast workflow alternative with manual cache management
2025-09-13 18:44:54 +02:00
denshooter
65ad26eeae Enhance container cleanup for zero-downtime deployment
Some checks failed
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m57s
CI/CD Pipeline (Simple) / production (push) Failing after 7m54s
- Add comprehensive cleanup of all portfolio-app containers
- Dynamically find and remove containers with portfolio-app in name
- Remove specific problematic container names (portfolio-app-new, etc.)
- Add container pruning to clean up stopped containers
- Ensure clean environment before starting new temporary container
- Prevents any container name conflicts during deployment
2025-09-13 18:21:36 +02:00
denshooter
20a6c416e3 Fix container name conflicts in zero-downtime deployment
Some checks failed
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m55s
CI/CD Pipeline (Simple) / production (push) Failing after 7m53s
- Use unique timestamp-based container names to avoid conflicts
- Clean up existing temporary containers before starting new ones
- Generate unique names like 'portfolio-app-temp-1234567890'
- Prevents 'container name already in use' errors
- Ensures reliable zero-downtime deployments
2025-09-13 01:24:34 +02:00
denshooter
89e0f9f2f8 Fix port conflict in zero-downtime deployment
Some checks failed
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m49s
CI/CD Pipeline (Simple) / production (push) Has been cancelled
- Remove port mapping for temporary container to avoid conflicts
- Use docker exec for health checks instead of external port access
- Eliminates 'port already allocated' error
- Maintains zero-downtime functionality without port conflicts
2025-09-13 01:13:43 +02:00
denshooter
8d627028cb Implement zero-downtime deployment strategy
Some checks failed
CI/CD Pipeline (Zero Downtime) / production (push) Failing after 7m51s
CI/CD Pipeline (Simple) / production (push) Has been cancelled
- Add rolling update mechanism for seamless deployments
- Start new container on port 3001, health check, then switch
- Preserve database and redis connections during updates
- Automatic fallback to fresh deployment if no current container
- Add advanced nginx load balancer configuration for future use
- Eliminate container name conflicts with proper cleanup
- Website stays online during deployments
2025-09-13 01:02:37 +02:00
denshooter
8afc63ef0b Separate CI/CD workflows for clarity
Some checks failed
CI/CD Pipeline (Simple) / production (push) Failing after 7m52s
- Split CI/CD into two distinct workflows:
  - 'Test and Build' for main branch (testing only)
  - 'CI/CD Pipeline' for production branch (full deployment)
- Remove duplicate test-and-build job from production workflow
- Each workflow now has a single, clear purpose
- Eliminates confusion with multiple job views in Gitea
2025-09-13 00:39:08 +02:00
denshooter
72456aa7a0 Fix Docker Compose command syntax
Some checks failed
CI/CD Pipeline (Simple) / test-and-build (push) Has been skipped
CI/CD Pipeline (Simple) / production (push) Has been cancelled
- Replace deprecated 'docker-compose' with modern 'docker compose'
- Update all workflow files to use new syntax
- Update documentation with correct commands
- Fixes 'command not found' error in CI/CD pipeline
- Compatible with Docker Compose V2 and newer versions
2025-09-13 00:35:02 +02:00
denshooter
4ccb2b146d Update workflows to match actual Variables and Secrets configuration
Some checks failed
CI/CD Pipeline (Simple) / test-and-build (push) Has been skipped
CI/CD Pipeline (Simple) / production (push) Failing after 7m54s
- Use Variables for non-sensitive data (NODE_ENV, LOG_LEVEL, URLs, emails)
- Use Secrets for sensitive data (passwords, auth tokens)
- Add all configured variables: NODE_ENV, LOG_LEVEL, UMAMI analytics
- Update verification checks to match actual configuration
- Improve debug workflow to show Variables vs Secrets clearly
- Fix environment variable mapping in Docker Compose deployment
2025-09-13 00:16:43 +02:00
denshooter
e245e8afe1 Support both Variables and Secrets in workflows
Some checks failed
CI/CD Pipeline (Simple) / test-and-build (push) Has been skipped
CI/CD Pipeline (Simple) / production (push) Has been cancelled
- Allow NEXT_PUBLIC_BASE_URL to be set as either Variable or Secret
- Update CI/CD workflow to check both secrets and variables
- Update debug workflow to show whether values come from secrets or variables
- Use fallback syntax: secrets.VAR || vars.VAR
- Improve error messages to guide users to correct settings location
2025-09-13 00:12:20 +02:00
denshooter
5a14efb5fc Make Docker mandatory for pre-push hook
Some checks failed
CI/CD Pipeline (Simple) / test-and-build (push) Has been skipped
CI/CD Pipeline (Simple) / production (push) Failing after 7m53s
- Docker must be running and functional before push is allowed
- Added comprehensive Docker status checks (info + hello-world test)
- Enhanced error messages with platform-specific Docker start instructions
- Improved build error reporting with detailed log output
- Added common troubleshooting tips for Docker build failures
- Push will fail if Docker is not available or build fails
2025-09-12 23:36:42 +02:00
denshooter
7f6694622c Fix React DOM warnings and improve pre-push hook
Some checks failed
CI/CD Pipeline (Simple) / test-and-build (push) Has been cancelled
CI/CD Pipeline (Simple) / production (push) Has been cancelled
- Fix fill and priority boolean attributes in Hero component
- Improve next/image mock in Jest setup to handle boolean props correctly
- Enhance pre-push hook with better Docker detection and error handling
- Make Docker build test non-blocking (warnings instead of errors)
- Add executable permissions for secret check script
- Prevent React DOM warnings in tests
2025-09-12 23:34:11 +02:00
denshooter
83705af7f6 cleanup and Test pre-push hook 2025-09-12 23:31:46 +02:00
306 changed files with 43937 additions and 10627 deletions

65
.dockerignore Normal file
View File

@@ -0,0 +1,65 @@
# Dependencies
node_modules
npm-debug.log
yarn-error.log
# Next.js
.next
out
build
dist
# Testing
coverage
.nyc_output
test-results
playwright-report
# Environment files
.env
.env.local
.env*.local
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
.gitattributes
# Documentation
*.md
docs
!README.md
# Logs
logs
*.log
# Docker
Dockerfile*
docker-compose*.yml
.dockerignore
# CI/CD
.gitea
.github
# Scripts (keep only essential ones)
scripts
!scripts/init-db.sql
!scripts/start-with-migrate.js
# Misc
.cache
.temp
tmp

View File

@@ -1,169 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches: [ main, production ]
pull_request:
branches: [ main, production ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
security:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@0.30.0
with:
scan-type: 'fs'
scan-ref: '.'
format: 'table'
output: 'trivy-results.txt'
timeout: '10m'
ignore-unfixed: true
severity: 'CRITICAL,HIGH'
continue-on-error: true
- name: Run npm audit as fallback
if: failure()
run: |
echo "Trivy failed, running npm audit as fallback..."
npm audit --audit-level=high || true
echo "Security scan completed with fallback method"
- name: Upload Trivy scan results
uses: actions/upload-artifact@v3
if: always()
with:
name: trivy-results
path: trivy-results.txt
retention-days: 7
build:
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/production'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build Docker image
run: |
docker build -t ${{ env.DOCKER_IMAGE }}:latest .
docker tag ${{ env.DOCKER_IMAGE }}:latest ${{ env.DOCKER_IMAGE }}:$(date +%Y%m%d-%H%M%S)
- name: Save Docker image
run: |
docker save ${{ env.DOCKER_IMAGE }}:latest | gzip > ${{ env.DOCKER_IMAGE }}.tar.gz
- name: Upload Docker image artifact
uses: actions/upload-artifact@v3
with:
name: docker-image
path: ${{ env.DOCKER_IMAGE }}.tar.gz
retention-days: 7
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/production'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker image artifact
uses: actions/download-artifact@v3
with:
name: docker-image
path: ./
- name: Load Docker image
run: |
gunzip -c ${{ env.DOCKER_IMAGE }}.tar.gz | docker load
- name: Stop existing services
run: |
docker-compose -f docker-compose.workflow.yml down || true
- name: Verify secrets before deployment
run: |
echo "🔍 Verifying secrets..."
if [ -z "${{ secrets.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL secret is missing!"
exit 1
fi
if [ -z "${{ secrets.MY_EMAIL }}" ]; then
echo "❌ MY_EMAIL secret is missing!"
exit 1
fi
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
exit 1
fi
echo "✅ All required secrets are present"
- name: Start services with Docker Compose
run: |
docker-compose -f docker-compose.workflow.yml up -d
env:
NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }}
MY_EMAIL: ${{ secrets.MY_EMAIL }}
MY_INFO_EMAIL: ${{ secrets.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
- name: Verify container environment
run: |
echo "🔍 Checking container environment variables..."
sleep 10
docker exec portfolio-app sh -c 'echo "NODE_ENV: $NODE_ENV" && echo "DATABASE_URL: $DATABASE_URL" && echo "REDIS_URL: $REDIS_URL" && echo "NEXT_PUBLIC_BASE_URL: $NEXT_PUBLIC_BASE_URL" && echo "MY_EMAIL: $MY_EMAIL" && echo "ADMIN_BASIC_AUTH: [HIDDEN]"'
- name: Wait for container to be ready
run: |
sleep 10
timeout 60 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done'
- name: Health check
run: |
curl -f http://localhost:3000/api/health
echo "✅ Deployment successful!"
- name: Cleanup old images
run: |
docker image prune -f
docker system prune -f

View File

@@ -0,0 +1,253 @@
name: CI/CD Pipeline (Using Gitea Variables & Secrets)
on:
push:
branches: [ dev, main, production ]
env:
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app
jobs:
production:
runs-on: ubuntu-latest
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
- name: Run tests
run: npm run test:production
- name: Build application
run: npm run build
- name: Run security scan
run: |
echo "🔍 Running npm audit..."
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
- name: Verify Gitea Variables and Secrets
run: |
echo "🔍 Verifying Gitea Variables and Secrets..."
# Check Variables
if [ -z "${{ vars.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL variable is missing!"
echo "Please set this variable in Gitea repository settings"
exit 1
fi
if [ -z "${{ vars.MY_EMAIL }}" ]; then
echo "❌ MY_EMAIL variable is missing!"
echo "Please set this variable in Gitea repository settings"
exit 1
fi
if [ -z "${{ vars.MY_INFO_EMAIL }}" ]; then
echo "❌ MY_INFO_EMAIL variable is missing!"
echo "Please set this variable in Gitea repository settings"
exit 1
fi
# Check Secrets
if [ -z "${{ secrets.MY_PASSWORD }}" ]; then
echo "❌ MY_PASSWORD secret is missing!"
echo "Please set this secret in Gitea repository settings"
exit 1
fi
if [ -z "${{ secrets.MY_INFO_PASSWORD }}" ]; then
echo "❌ MY_INFO_PASSWORD secret is missing!"
echo "Please set this secret in Gitea repository settings"
exit 1
fi
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
echo "Please set this secret in Gitea repository settings"
exit 1
fi
echo "✅ All required Gitea variables and secrets are present"
echo "📝 Variables found:"
echo " - NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}"
echo " - MY_EMAIL: ${{ vars.MY_EMAIL }}"
echo " - MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}"
echo " - NODE_ENV: ${{ vars.NODE_ENV }}"
echo " - LOG_LEVEL: ${{ vars.LOG_LEVEL }}"
- name: Build Docker image
run: |
echo "🏗️ Building Docker image..."
docker build -t ${{ env.DOCKER_IMAGE }}:latest .
docker tag ${{ env.DOCKER_IMAGE }}:latest ${{ env.DOCKER_IMAGE }}:$(date +%Y%m%d-%H%M%S)
echo "✅ Docker image built successfully"
- name: Deploy using Gitea Variables and Secrets
run: |
# Determine if this is staging or production
if [ "${{ github.ref }}" == "refs/heads/dev" ] || [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "🚀 Deploying Staging using Gitea Variables and Secrets..."
COMPOSE_FILE="docker-compose.staging.yml"
HEALTH_PORT="3002"
CONTAINER_NAME="portfolio-app-staging"
DEPLOY_ENV="staging"
else
echo "🚀 Deploying Production using Gitea Variables and Secrets..."
COMPOSE_FILE="docker-compose.production.yml"
HEALTH_PORT="3000"
CONTAINER_NAME="portfolio-app"
DEPLOY_ENV="production"
fi
echo "📝 Using Gitea Variables and Secrets:"
echo " - NODE_ENV: ${DEPLOY_ENV}"
echo " - LOG_LEVEL: ${LOG_LEVEL}"
echo " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
echo " - MY_EMAIL: ${MY_EMAIL}"
echo " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
echo " - MY_PASSWORD: [SET FROM GITEA SECRET]"
echo " - MY_INFO_PASSWORD: [SET FROM GITEA SECRET]"
echo " - ADMIN_BASIC_AUTH: [SET FROM GITEA SECRET]"
echo " - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-}"
# Stop old containers (only for the environment being deployed)
echo "🛑 Stopping old ${DEPLOY_ENV} containers..."
docker compose -f $COMPOSE_FILE down || true
# Clean up orphaned containers
echo "🧹 Cleaning up orphaned ${DEPLOY_ENV} containers..."
docker compose -f $COMPOSE_FILE down --remove-orphans || true
# Start new containers
echo "🚀 Starting new ${DEPLOY_ENV} containers..."
docker compose -f $COMPOSE_FILE up -d --force-recreate
# Wait a moment for containers to start
echo "⏳ Waiting for ${DEPLOY_ENV} containers to start..."
sleep 15
# Check container logs for debugging
echo "📋 ${DEPLOY_ENV} container logs (first 30 lines):"
docker compose -f $COMPOSE_FILE logs --tail=30
echo "✅ ${DEPLOY_ENV} deployment completed!"
env:
NODE_ENV: ${{ vars.NODE_ENV || 'production' }}
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
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 }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Wait for containers to be ready
run: |
# Determine environment
if [ "${{ github.ref }}" == "refs/heads/dev" ] || [ "${{ github.ref }}" == "refs/heads/main" ]; then
COMPOSE_FILE="docker-compose.staging.yml"
HEALTH_PORT="3002"
CONTAINER_NAME="portfolio-app-staging"
DEPLOY_ENV="staging"
else
COMPOSE_FILE="docker-compose.production.yml"
HEALTH_PORT="3000"
CONTAINER_NAME="portfolio-app"
DEPLOY_ENV="production"
fi
echo "⏳ Waiting for ${DEPLOY_ENV} containers to be ready..."
sleep 30
# Check if all containers are running
echo "📊 Checking ${DEPLOY_ENV} container status..."
docker compose -f $COMPOSE_FILE ps
# Wait for application container to be healthy
echo "🏥 Waiting for ${DEPLOY_ENV} application container to be healthy..."
for i in {1..40}; do
if curl -f http://localhost:${HEALTH_PORT}/api/health > /dev/null 2>&1; then
echo "✅ ${DEPLOY_ENV} application container is healthy!"
break
fi
echo "⏳ Waiting for ${DEPLOY_ENV} application container... ($i/40)"
sleep 3
done
# Additional wait for main page to be accessible
echo "🌐 Waiting for ${DEPLOY_ENV} main page to be accessible..."
for i in {1..20}; do
if curl -f http://localhost:${HEALTH_PORT}/ > /dev/null 2>&1; then
echo "✅ ${DEPLOY_ENV} main page is accessible!"
break
fi
echo "⏳ Waiting for ${DEPLOY_ENV} main page... ($i/20)"
sleep 2
done
- name: Health check
run: |
# Determine environment
if [ "${{ github.ref }}" == "refs/heads/dev" ] || [ "${{ github.ref }}" == "refs/heads/main" ]; then
COMPOSE_FILE="docker-compose.staging.yml"
HEALTH_PORT="3002"
CONTAINER_NAME="portfolio-app-staging"
DEPLOY_ENV="staging"
else
COMPOSE_FILE="docker-compose.production.yml"
HEALTH_PORT="3000"
CONTAINER_NAME="portfolio-app"
DEPLOY_ENV="production"
fi
echo "🔍 Running comprehensive ${DEPLOY_ENV} health checks..."
# Check container status
echo "📊 ${DEPLOY_ENV} container status:"
docker compose -f $COMPOSE_FILE ps
# Check application container
echo "🏥 Checking ${DEPLOY_ENV} application container..."
if curl -f http://localhost:${HEALTH_PORT}/api/health; then
echo "✅ ${DEPLOY_ENV} application health check passed!"
else
echo "⚠️ ${DEPLOY_ENV} application health check failed, but continuing..."
docker compose -f $COMPOSE_FILE logs --tail=50
# Don't exit 1 for staging, only for production
if [ "$DEPLOY_ENV" == "production" ]; then
exit 1
fi
fi
# Check main page
if curl -f http://localhost:${HEALTH_PORT}/ > /dev/null; then
echo "✅ ${DEPLOY_ENV} main page is accessible!"
else
echo "⚠️ ${DEPLOY_ENV} main page check failed, but continuing..."
if [ "$DEPLOY_ENV" == "production" ]; then
exit 1
fi
fi
echo "✅ ${DEPLOY_ENV} health checks completed!"
- name: Cleanup old images
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
docker system prune -f
echo "✅ Cleanup completed"

View File

@@ -1,127 +0,0 @@
name: CI/CD Pipeline (Simple)
on:
push:
branches: [ main, production ]
pull_request:
branches: [ main, production ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app
jobs:
# Single job that does everything for non-production branches
test-and-build:
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/production'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
- name: Run security scan
run: |
echo "🔍 Running npm audit..."
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
# Production deployment pipeline
production:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/production'
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
- name: Run security scan
run: |
echo "🔍 Running npm audit..."
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
- name: Build Docker image
run: |
docker build -t ${{ env.DOCKER_IMAGE }}:latest .
docker tag ${{ env.DOCKER_IMAGE }}:latest ${{ env.DOCKER_IMAGE }}:$(date +%Y%m%d-%H%M%S)
- name: Stop existing services
run: |
docker-compose -f docker-compose.workflow.yml down || true
- name: Verify secrets before deployment
run: |
echo "🔍 Verifying secrets..."
if [ -z "${{ secrets.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL secret is missing!"
exit 1
fi
if [ -z "${{ secrets.MY_EMAIL }}" ]; then
echo "❌ MY_EMAIL secret is missing!"
exit 1
fi
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
exit 1
fi
echo "✅ All required secrets are present"
- name: Start services with Docker Compose
run: |
docker-compose -f docker-compose.workflow.yml up -d
env:
NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }}
MY_EMAIL: ${{ secrets.MY_EMAIL }}
MY_INFO_EMAIL: ${{ secrets.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
- name: Wait for container to be ready
run: |
sleep 10
timeout 60 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done'
- name: Health check
run: |
curl -f http://localhost:3000/api/health
echo "✅ Deployment successful!"
- name: Cleanup old images
run: |
docker image prune -f
docker system prune -f

32
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,32 @@
name: Gitea CI
on:
push:
branches: [main, dev, production]
pull_request:
branches: [main, dev, production]
jobs:
test-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install deps
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Build
run: npm run build

View File

@@ -1,126 +0,0 @@
name: Debug Secrets
on:
workflow_dispatch:
push:
branches: [ main ]
jobs:
debug-secrets:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Debug Environment Variables
run: |
echo "🔍 Checking if secrets are available..."
echo ""
# Check each secret (without revealing values)
if [ -n "${{ secrets.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "✅ NEXT_PUBLIC_BASE_URL: Set (length: ${#NEXT_PUBLIC_BASE_URL})"
else
echo "❌ NEXT_PUBLIC_BASE_URL: Not set"
fi
if [ -n "${{ secrets.MY_EMAIL }}" ]; then
echo "✅ MY_EMAIL: Set (length: ${#MY_EMAIL})"
else
echo "❌ MY_EMAIL: Not set"
fi
if [ -n "${{ secrets.MY_INFO_EMAIL }}" ]; then
echo "✅ MY_INFO_EMAIL: Set (length: ${#MY_INFO_EMAIL})"
else
echo "❌ MY_INFO_EMAIL: Not set"
fi
if [ -n "${{ secrets.MY_PASSWORD }}" ]; then
echo "✅ MY_PASSWORD: Set (length: ${#MY_PASSWORD})"
else
echo "❌ MY_PASSWORD: Not set"
fi
if [ -n "${{ secrets.MY_INFO_PASSWORD }}" ]; then
echo "✅ MY_INFO_PASSWORD: Set (length: ${#MY_INFO_PASSWORD})"
else
echo "❌ MY_INFO_PASSWORD: Not set"
fi
if [ -n "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "✅ ADMIN_BASIC_AUTH: Set (length: ${#ADMIN_BASIC_AUTH})"
else
echo "❌ ADMIN_BASIC_AUTH: Not set"
fi
echo ""
echo "📋 Summary:"
echo "Total secrets checked: 6"
echo "Set secrets: $(echo "${{ secrets.NEXT_PUBLIC_BASE_URL }}${{ secrets.MY_EMAIL }}${{ secrets.MY_INFO_EMAIL }}${{ secrets.MY_PASSWORD }}${{ secrets.MY_INFO_PASSWORD }}${{ secrets.ADMIN_BASIC_AUTH }}" | grep -o . | wc -l)"
env:
NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }}
MY_EMAIL: ${{ secrets.MY_EMAIL }}
MY_INFO_EMAIL: ${{ secrets.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
- name: Test Docker Environment
run: |
echo "🐳 Testing Docker environment with secrets..."
# Create a test container to verify environment variables
docker run --rm \
-e NODE_ENV=production \
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
-e REDIS_URL=redis://redis:6379 \
-e NEXT_PUBLIC_BASE_URL="${{ secrets.NEXT_PUBLIC_BASE_URL }}" \
-e MY_EMAIL="${{ secrets.MY_EMAIL }}" \
-e MY_INFO_EMAIL="${{ secrets.MY_INFO_EMAIL }}" \
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
alpine:latest sh -c '
echo "Environment variables in container:"
echo "NODE_ENV: $NODE_ENV"
echo "DATABASE_URL: $DATABASE_URL"
echo "REDIS_URL: $REDIS_URL"
echo "NEXT_PUBLIC_BASE_URL: $NEXT_PUBLIC_BASE_URL"
echo "MY_EMAIL: $MY_EMAIL"
echo "MY_INFO_EMAIL: $MY_INFO_EMAIL"
echo "MY_PASSWORD: [HIDDEN - length: ${#MY_PASSWORD}]"
echo "MY_INFO_PASSWORD: [HIDDEN - length: ${#MY_INFO_PASSWORD}]"
echo "ADMIN_BASIC_AUTH: [HIDDEN - length: ${#ADMIN_BASIC_AUTH}]"
'
- name: Validate Secret Formats
run: |
echo "🔐 Validating secret formats..."
# Check NEXT_PUBLIC_BASE_URL format
if [[ "${{ secrets.NEXT_PUBLIC_BASE_URL }}" =~ ^https?:// ]]; then
echo "✅ NEXT_PUBLIC_BASE_URL: Valid URL format"
else
echo "❌ NEXT_PUBLIC_BASE_URL: Invalid URL format (should start with http:// or https://)"
fi
# Check email formats
if [[ "${{ secrets.MY_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "✅ MY_EMAIL: Valid email format"
else
echo "❌ MY_EMAIL: Invalid email format"
fi
if [[ "${{ secrets.MY_INFO_EMAIL }}" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "✅ MY_INFO_EMAIL: Valid email format"
else
echo "❌ MY_INFO_EMAIL: Invalid email format"
fi
# Check ADMIN_BASIC_AUTH format (should be username:password)
if [[ "${{ secrets.ADMIN_BASIC_AUTH }}" =~ ^[^:]+:.+$ ]]; then
echo "✅ ADMIN_BASIC_AUTH: Valid format (username:password)"
else
echo "❌ ADMIN_BASIC_AUTH: Invalid format (should be username:password)"
fi

View File

@@ -0,0 +1,300 @@
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"

View File

@@ -0,0 +1,280 @@
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"

View File

@@ -1,78 +0,0 @@
name: Quick Deploy
on:
push:
branches: [ main ]
workflow_dispatch:
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app
jobs:
quick-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Build Docker image
run: |
docker build -t ${{ env.DOCKER_IMAGE }}:latest .
- name: Stop existing services
run: |
docker-compose -f docker-compose.workflow.yml down || true
- name: Verify secrets before deployment
run: |
echo "🔍 Verifying secrets..."
if [ -z "${{ secrets.NEXT_PUBLIC_BASE_URL }}" ]; then
echo "❌ NEXT_PUBLIC_BASE_URL secret is missing!"
exit 1
fi
if [ -z "${{ secrets.MY_EMAIL }}" ]; then
echo "❌ MY_EMAIL secret is missing!"
exit 1
fi
if [ -z "${{ secrets.ADMIN_BASIC_AUTH }}" ]; then
echo "❌ ADMIN_BASIC_AUTH secret is missing!"
exit 1
fi
echo "✅ All required secrets are present"
- name: Start services with Docker Compose
run: |
docker-compose -f docker-compose.workflow.yml up -d
env:
NEXT_PUBLIC_BASE_URL: ${{ secrets.NEXT_PUBLIC_BASE_URL }}
MY_EMAIL: ${{ secrets.MY_EMAIL }}
MY_INFO_EMAIL: ${{ secrets.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
- name: Verify container environment
run: |
echo "🔍 Checking container environment variables..."
sleep 10
docker exec portfolio-app sh -c 'echo "NODE_ENV: $NODE_ENV" && echo "DATABASE_URL: $DATABASE_URL" && echo "REDIS_URL: $REDIS_URL" && echo "NEXT_PUBLIC_BASE_URL: $NEXT_PUBLIC_BASE_URL" && echo "MY_EMAIL: $MY_EMAIL" && echo "ADMIN_BASIC_AUTH: [HIDDEN]"'
- name: Health check
run: |
sleep 10
curl -f http://localhost:3000/api/health
echo "✅ Quick deployment successful!"

View File

@@ -1,78 +0,0 @@
name: Security Scan
on:
push:
branches: [ main, production ]
pull_request:
branches: [ main, production ]
schedule:
- cron: '0 2 * * 1' # Weekly on Monday at 2 AM
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run npm audit
run: |
echo "🔍 Running npm audit for dependency vulnerabilities..."
npm audit --audit-level=high --json > npm-audit-results.json || true
npm audit --audit-level=high || echo "⚠️ Some vulnerabilities found, but continuing..."
- name: Run Trivy (with fallback)
run: |
echo "🔍 Attempting Trivy scan..."
# Try to install and run Trivy directly
wget -qO- https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
trivy fs --scanners vuln,secret --format table . > trivy-results.txt 2>&1 || {
echo "⚠️ Trivy scan failed, but continuing with other checks..."
echo "Trivy scan failed due to network issues" > trivy-results.txt
}
- name: Check for secrets
run: |
echo "🔍 Checking for potential secrets..."
chmod +x scripts/check-secrets.sh
if ./scripts/check-secrets.sh; then
echo "✅ No secrets found in code"
else
echo "❌ Secrets detected - please review"
exit 1
fi
- name: Upload security scan results
uses: actions/upload-artifact@v3
if: always()
with:
name: security-scan-results
path: |
npm-audit-results.json
trivy-results.txt
retention-days: 30
- name: Security scan summary
run: |
echo "## Security Scan Summary"
echo "### NPM Audit Results"
if [ -f npm-audit-results.json ]; then
echo "✅ NPM audit completed"
else
echo "❌ NPM audit failed"
fi
echo "### Trivy Results"
if [ -f trivy-results.txt ]; then
echo "✅ Trivy scan completed"
else
echo "❌ Trivy scan failed"
fi

View File

@@ -0,0 +1,155 @@
name: Staging Deployment
on:
push:
branches: [ dev, main ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app-staging
jobs:
staging:
runs-on: ubuntu-latest
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
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building Docker image for staging..."
docker build -t ${{ env.DOCKER_IMAGE }}:staging .
docker tag ${{ env.DOCKER_IMAGE }}:staging ${{ env.DOCKER_IMAGE }}:staging-$(date +%Y%m%d-%H%M%S)
echo "✅ Docker image built successfully"
- name: Deploy Staging using Gitea Variables and Secrets
run: |
echo "🚀 Deploying Staging using Gitea Variables and Secrets..."
echo "📝 Using Gitea Variables and Secrets:"
echo " - NODE_ENV: staging"
echo " - LOG_LEVEL: ${LOG_LEVEL:-info}"
echo " - NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL}"
echo " - MY_EMAIL: ${MY_EMAIL}"
echo " - MY_INFO_EMAIL: ${MY_INFO_EMAIL}"
echo " - MY_PASSWORD: [SET FROM GITEA SECRET]"
echo " - MY_INFO_PASSWORD: [SET FROM GITEA SECRET]"
echo " - ADMIN_BASIC_AUTH: [SET FROM GITEA SECRET]"
echo " - N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-}"
# Stop old staging containers only
echo "🛑 Stopping old staging containers..."
docker compose -f docker-compose.staging.yml down || true
# Clean up orphaned staging containers
echo "🧹 Cleaning up orphaned staging containers..."
docker compose -f docker-compose.staging.yml down --remove-orphans || true
# Start new staging containers
echo "🚀 Starting new staging containers..."
docker compose -f docker-compose.staging.yml up -d --force-recreate
# Wait a moment for containers to start
echo "⏳ Waiting for staging containers to start..."
sleep 15
# Check container logs for debugging
echo "📋 Staging container logs (first 30 lines):"
docker compose -f docker-compose.staging.yml logs --tail=30
echo "✅ Staging deployment completed!"
env:
NODE_ENV: staging
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL }}
NEXT_PUBLIC_UMAMI_URL: ${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
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 }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Wait for staging to be ready
run: |
echo "⏳ Waiting for staging application to be ready..."
sleep 30
# Check if all staging containers are running
echo "📊 Checking staging container status..."
docker compose -f docker-compose.staging.yml ps
# Wait for application container to be healthy
echo "🏥 Waiting for staging application container to be healthy..."
for i in {1..40}; do
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
echo "✅ Staging application container is healthy!"
break
fi
echo "⏳ Waiting for staging application container... ($i/40)"
sleep 3
done
# Additional wait for main page to be accessible
echo "🌐 Waiting for staging main page to be accessible..."
for i in {1..20}; do
if curl -f http://localhost:3002/ > /dev/null 2>&1; then
echo "✅ Staging main page is accessible!"
break
fi
echo "⏳ Waiting for staging main page... ($i/20)"
sleep 2
done
- name: Staging health check
run: |
echo "🔍 Running staging health checks..."
# Check container status
echo "📊 Staging container status:"
docker compose -f docker-compose.staging.yml ps
# Check application container
echo "🏥 Checking staging application container..."
if curl -f http://localhost:3002/api/health; then
echo "✅ Staging application health check passed!"
else
echo "⚠️ Staging application health check failed, but continuing..."
docker compose -f docker-compose.staging.yml logs --tail=50
fi
# Check main page
if curl -f http://localhost:3002/ > /dev/null; then
echo "✅ Staging main page is accessible!"
else
echo "⚠️ Staging main page check failed, but continuing..."
fi
echo "✅ Staging deployment verification completed!"
- name: Cleanup old staging images
run: |
echo "🧹 Cleaning up old staging images..."
docker image prune -f --filter "label=stage=staging" || true
echo "✅ Cleanup completed"

78
.githooks/README.md Normal file
View File

@@ -0,0 +1,78 @@
# Git Hooks
This directory contains Git hooks for the Portfolio project.
## Pre-Push Hook
The pre-push hook runs automatically before every `git push` and performs the following checks:
### Checks Performed:
1. **Node.js Version Check** - Ensures Node.js 20+ is installed
2. **Dependency Installation** - Installs npm dependencies if needed
3. **Linting** - Runs ESLint to check code quality
4. **Tests** - Runs Jest test suite
5. **Build** - Builds the Next.js application
6. **Security Audit** - Runs npm audit for vulnerabilities
7. **Secret Detection** - Checks for accidentally committed secrets
8. **Docker Configuration** - Validates Dockerfile and docker-compose.yml
9. **Production Checks** - Additional checks when pushing to production branch
### Production Branch Special Checks:
- Environment file validation
- Docker build test
- Deployment readiness check
### Usage:
The hook runs automatically on every push. To manually test it:
```bash
# Test the hook manually
.githooks/pre-push
# Or push to trigger it
git push origin main
```
### Bypassing the Hook:
If you need to bypass the hook in an emergency:
```bash
git push --no-verify origin main
```
**Note**: Only bypass in emergencies. The hook prevents broken code from being pushed.
### Troubleshooting:
If the hook fails:
1. **Fix the reported issues** (linting errors, test failures, etc.)
2. **Run the checks manually** to debug:
```bash
npm run lint
npm run test
npm run build
npm audit
```
3. **Check Node.js version**: `node --version` (should be 20+)
4. **Reinstall dependencies**: `rm -rf node_modules && npm ci`
### Configuration:
The hook is configured in `.git/config`:
```
[core]
hooksPath = .githooks
```
To disable hooks temporarily:
```bash
git config core.hooksPath ""
```
To re-enable:
```bash
git config core.hooksPath .githooks
```

202
.githooks/pre-push Executable file
View File

@@ -0,0 +1,202 @@
#!/bin/bash
# Pre-push hook for Portfolio
# Runs CI/CD checks before allowing push
set -e
echo "🚀 Running pre-push checks..."
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if we're in the right directory
if [ ! -f "package.json" ]; then
print_error "Not in project root directory!"
exit 1
fi
# Check if Node.js is available
if ! command -v node &> /dev/null; then
print_error "Node.js is not installed!"
exit 1
fi
# Check Node.js version
NODE_VERSION=$(node --version | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 20 ]; then
print_error "Node.js version 20+ required, found: $(node --version)"
exit 1
fi
print_success "Node.js version: $(node --version)"
# Install dependencies if node_modules doesn't exist
if [ ! -d "node_modules" ]; then
print_status "Installing dependencies..."
npm ci
else
print_status "Dependencies already installed"
fi
# Run linting
print_status "Running ESLint..."
if npm run lint; then
print_success "Linting passed"
else
print_error "Linting failed! Please fix the issues before pushing."
exit 1
fi
# Run tests
print_status "Running tests..."
if npm run test:production; then
print_success "Tests passed"
else
print_error "Tests failed! Please fix the issues before pushing."
exit 1
fi
# Build application
print_status "Building application..."
if npm run build; then
print_success "Build successful"
else
print_error "Build failed! Please fix the issues before pushing."
exit 1
fi
# Security audit
print_status "Running security audit..."
if npm audit --audit-level=high; then
print_success "Security audit passed"
else
print_warning "Security audit found issues. Consider running 'npm audit fix'"
# Don't fail the push for security warnings, just warn
fi
# Check for secrets in code
print_status "Checking for secrets in code..."
if [ -f "scripts/check-secrets.sh" ]; then
chmod +x scripts/check-secrets.sh
if ./scripts/check-secrets.sh; then
print_success "No secrets found in code"
else
print_error "Secrets detected in code! Please remove them before pushing."
exit 1
fi
else
print_warning "Secret check script not found, skipping..."
fi
# Check Docker configuration
print_status "Checking Docker configuration..."
if [ -f "Dockerfile" ]; then
print_success "Dockerfile found"
else
print_error "Dockerfile not found!"
exit 1
fi
if [ -f "docker-compose.yml" ]; then
print_success "Docker Compose configuration found"
else
print_error "Docker Compose configuration not found!"
exit 1
fi
# Check if we're pushing to production branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "production" ]; then
print_warning "Pushing to production branch - this will trigger deployment!"
# Additional production checks
print_status "Running production-specific checks..."
# Check if environment file exists
if [ ! -f ".env" ]; then
print_warning "No .env file found. Make sure secrets are configured in Gitea."
fi
# Check if Docker is running and ready
print_status "Checking Docker status..."
if ! docker info > /dev/null 2>&1; then
print_error "Docker is not running! Please start Docker before pushing."
print_status "To start Docker:"
print_status " - macOS: Open Docker Desktop application"
print_status " - Linux: sudo systemctl start docker"
print_status " - Windows: Start Docker Desktop application"
print_status ""
print_status "Wait for Docker to fully start before trying again."
exit 1
fi
# Test Docker functionality
if ! docker run --rm hello-world > /dev/null 2>&1; then
print_error "Docker is running but not functional!"
print_status "Docker might still be starting up. Please wait and try again."
print_status "Or restart Docker if the issue persists."
exit 1
fi
print_success "Docker is running and functional"
# Check Docker image can be built
print_status "Testing Docker build..."
# Create a temporary log file for build output
BUILD_LOG=$(mktemp)
if docker build -t portfolio-app:test . > "$BUILD_LOG" 2>&1; then
print_success "Docker build test passed"
docker rmi portfolio-app:test > /dev/null 2>&1
rm -f "$BUILD_LOG"
else
print_error "Docker build test failed!"
print_status "Build errors:"
echo "----------------------------------------"
cat "$BUILD_LOG"
echo "----------------------------------------"
print_status "Please fix Docker build issues before pushing."
print_status "Common issues:"
print_status " - Missing files referenced in Dockerfile"
print_status " - Network issues during npm install"
print_status " - Insufficient disk space"
rm -f "$BUILD_LOG"
exit 1
fi
fi
# Final success message
echo ""
print_success "All pre-push checks passed! ✅"
print_status "Ready to push to: $CURRENT_BRANCH"
# Show what will be pushed
echo ""
print_status "Files to be pushed:"
git diff --name-only HEAD~1 2>/dev/null || git diff --cached --name-only
echo ""
print_success "🚀 Push will proceed..."

211
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,211 @@
# Portfolio Project Instructions
This is Dennis Konkol's personal portfolio (dk0.dev) - a Next.js 15 portfolio with Directus CMS integration, n8n automation, and a "liquid" design system.
## Build, Test, and Lint
### Development
```bash
npm run dev # Full dev environment (Docker + Next.js)
npm run dev:simple # Next.js only (no Docker dependencies)
npm run dev:next # Plain Next.js dev server
```
### Build & Deploy
```bash
npm run build # Production build (standalone mode)
npm run start # Start production server
```
### Testing
```bash
# Unit tests (Jest)
npm run test # Run all unit tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage report
# E2E tests (Playwright)
npm run test:e2e # Run all E2E tests
npm run test:e2e:ui # Interactive UI mode
npm run test:critical # Critical paths only
npm run test:hydration # Hydration tests only
```
### Linting
```bash
npm run lint # Run ESLint
npm run lint:fix # Auto-fix issues
```
### Database (Prisma)
```bash
npm run db:generate # Generate Prisma client
npm run db:push # Push schema to database
npm run db:studio # Open Prisma Studio
npm run db:seed # Seed database
```
## Architecture Overview
### Tech Stack
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
- **Theming**: next-themes for dark mode (system/light/dark)
- **Animations**: Framer Motion 12
- **3D**: Three.js + React Three Fiber (shader gradient background)
- **Database**: PostgreSQL via Prisma ORM
- **Cache**: Redis (optional)
- **CMS**: Directus (self-hosted, GraphQL, optional)
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
- **i18n**: next-intl (EN + DE)
- **Monitoring**: Sentry
- **Deployment**: Docker (standalone mode) + Nginx
### Key Directories
```
app/
[locale]/ # i18n routes (en, de)
page.tsx # Homepage sections
projects/ # Project listing + detail pages
api/ # API routes
book-reviews/ # Book reviews from Directus
hobbies/ # Hobbies from Directus
n8n/ # n8n webhook proxies
projects/ # Projects (PostgreSQL + Directus)
tech-stack/ # Tech stack from Directus
components/ # React components
lib/
directus.ts # Directus GraphQL client (no SDK)
auth.ts # Auth + rate limiting
translations-loader.ts # i18n loaders for server components
prisma/
schema.prisma # Database schema
messages/
en.json # English translations
de.json # German translations
```
### Data Source Fallback Chain
The architecture prioritizes resilience with this fallback hierarchy:
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured)
2. **PostgreSQL** (for projects, analytics)
3. **JSON files** (`messages/*.json`)
4. **Hardcoded defaults**
5. **Display key itself** (last resort)
**Critical**: The site never crashes if external services (Directus, PostgreSQL, n8n, Redis) are unavailable. All API routes return graceful fallbacks.
### CMS Integration (Directus)
- GraphQL calls via `lib/directus.ts` (no Directus SDK)
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
- Translations use Directus native system (M2O to `languages`)
- Locale mapping: `en``en-US`, `de``de-DE`
- API routes export `runtime='nodejs'`, `dynamic='force-dynamic'` and include a `source` field in JSON responses (`directus|fallback|error`)
### n8n Integration
- Webhook base URL: `N8N_WEBHOOK_URL` env var
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- All endpoints have rate limiting and 10s timeout protection
- Hardcover reading data cached for 5 minutes
## Key Conventions
### i18n (Internationalization)
- **Supported locales**: `en` (English), `de` (German)
- **Primary source**: Static JSON files in `messages/en.json` and `messages/de.json`
- **Optional override**: Directus CMS `messages` collection
- **Server components**: Use `getHeroTranslations()`, `getNavTranslations()`, etc. from `lib/translations-loader.ts`
- **Client components**: Use `useTranslations("key.path")` from next-intl
- **Locale mapping**: Middleware defines `["en", "de"]` which must match `app/[locale]/layout.tsx`
### Component Patterns
- **Client components**: Mark with `"use client"` for interactive/data-fetching parts
- **Data loading**: Use `useEffect` for client-side fetching on mount
- **Animations**: Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
- **Loading states**: Every async component needs a matching Skeleton component
### Design System ("Liquid Editorial Bento")
- **Core palette**: Cream (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`)
- **Custom colors**: Prefixed with `liquid-*` (sky, mint, lavender, pink, rose, peach, coral, teal, lime)
- **Card style**: Gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`)
- **Glassmorphism**: Use `backdrop-blur-sm` with `border-2` and `rounded-xl`
- **Typography**: Headlines uppercase, tracking-tighter, with accent point at end
- **Layout**: Bento Grid for new features (no floating overlays)
### File Naming
- **Components**: PascalCase in `app/components/` (e.g., `About.tsx`)
- **API routes**: kebab-case directories in `app/api/` (e.g., `book-reviews/`)
- **Lib utilities**: kebab-case in `lib/` (e.g., `email-obfuscate.ts`)
### Code Style
- **Language**: Code in English, user-facing text via i18n
- **TypeScript**: No `any` types - use interfaces from `lib/directus.ts` or `app/_ui/`
- **Error handling**: All API calls must catch errors with fallbacks
- **Error logging**: Only in development mode (`process.env.NODE_ENV === "development"`)
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
- **No emojis**: Unless explicitly requested
### Testing Notes
- **Jest environment**: JSDOM with mocks for `window.matchMedia` and `IntersectionObserver`
- **Playwright**: Uses plain Next.js dev server (no Docker) with `NODE_ENV=development` to avoid Edge runtime issues
- **Transform**: ESM modules (react-markdown, remark-*, etc.) are transformed via `transformIgnorePatterns`
- **After UI changes**: Run `npm run test` to verify no regressions
### Docker & Deployment
- **Standalone mode**: `next.config.ts` uses `output: "standalone"` for optimized Docker builds
- **Branches**: `dev` → staging, `production` → live
- **CI/CD**: Gitea Actions (`.gitea/workflows/`)
- **Verify Docker builds**: Always test Docker builds after changes to `next.config.ts` or dependencies
## Common Tasks
### Adding a CMS-managed section
1. Define GraphQL query + types in `lib/directus.ts`
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
3. Create component in `app/components/<Name>.tsx`
4. Add i18n keys to `messages/en.json` and `messages/de.json`
5. Integrate into parent component
### Adding i18n strings
1. Add keys to both `messages/en.json` and `messages/de.json`
2. Use `useTranslations("key.path")` in client components
3. Use `getTranslations("key.path")` in server components
### Working with Directus
- All queries go through `directusRequest()` in `lib/directus.ts`
- Uses GraphQL endpoint (`/graphql`) with 2s timeout
- Returns `null` on failure (graceful degradation)
- Translations filtered by `languages_code.code` matching Directus locale
## Environment Variables
### Required for CMS
```bash
DIRECTUS_URL=https://cms.dk0.dev
DIRECTUS_STATIC_TOKEN=...
```
### Required for n8n features
```bash
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=...
N8N_API_KEY=...
```
### Database & Cache
```bash
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
```
### Optional
```bash
SENTRY_DSN=...
NEXT_PUBLIC_BASE_URL=https://dk0.dev
```
## Documentation References
- Operations guide: `docs/OPERATIONS.md`
- Locale system: `docs/LOCALE_SYSTEM.md`
- CMS guide: `docs/CMS_GUIDE.md`
- Testing & deployment: `docs/TESTING_AND_DEPLOYMENT.md`

View File

@@ -1,218 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches: [main, production]
pull_request:
branches: [main, production]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Test Job (parallel)
test:
name: Run Tests
runs-on: self-hosted # Use your own server for speed!
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Create test environment file
run: |
cat > .env <<EOF
NODE_ENV=test
NEXT_PUBLIC_BASE_URL=http://localhost:3000
MY_EMAIL=test@example.com
MY_INFO_EMAIL=test@example.com
MY_PASSWORD=test
MY_INFO_PASSWORD=test
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
ADMIN_BASIC_AUTH=admin:test
LOG_LEVEL=info
EOF
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
# Security scan (parallel)
security:
name: Security Scan
runs-on: self-hosted # Use your own server for speed!
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
skip-version-check: true
scanners: 'vuln,secret,config'
- name: Upload Trivy scan results as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: trivy-security-report
path: trivy-results.sarif
retention-days: 30
# Build and push Docker image
build:
name: Build and Push Docker Image
runs-on: self-hosted # Use your own server for speed!
needs: [test, security] # Wait for parallel jobs to complete
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/production')
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Create production environment file
run: |
cat > .env <<EOF
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
MY_EMAIL=${{ vars.MY_EMAIL }}
MY_INFO_EMAIL=${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD=${{ secrets.MY_INFO_PASSWORD }}
NEXT_PUBLIC_UMAMI_URL=${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
ADMIN_BASIC_AUTH=${{ secrets.ADMIN_BASIC_AUTH }}
LOG_LEVEL=info
EOF
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64 # Only AMD64 for speed
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Optimize for speed
build-args: |
BUILDKIT_INLINE_CACHE=1
# Deploy to server
deploy:
name: Deploy to Server
runs-on: self-hosted
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to server
run: |
# Set deployment variables
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production"
export CONTAINER_NAME="portfolio-app"
export COMPOSE_FILE="docker-compose.prod.yml"
# Set environment variables for docker-compose
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
export MY_EMAIL="${{ vars.MY_EMAIL }}"
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
# Pull latest image
docker pull $IMAGE_NAME
# Stop and remove old container
docker compose -f $COMPOSE_FILE down || true
# Remove old images to force using new one
docker image prune -f
# Start new container with force recreate
docker compose -f $COMPOSE_FILE up -d --force-recreate
# Wait for health check
echo "Waiting for application to be healthy..."
timeout 60 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done'
# Verify deployment
if curl -f http://localhost:3000/api/health; then
echo "✅ Deployment successful!"
else
echo "❌ Deployment failed!"
docker compose -f $COMPOSE_FILE logs
exit 1
fi
- name: Cleanup old images
run: |
# Remove unused images older than 7 days
docker image prune -f --filter "until=168h"
# Remove unused containers
docker container prune -f

25
.gitignore vendored
View File

@@ -1,5 +1,9 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Local tooling
.claude/
._*
# dependencies
/node_modules
/.pnp
@@ -33,9 +37,30 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# Sentry
.sentryclirc
sentry.properties
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# logs
logs/*.log
*.log
# test results
test-results/
playwright-report/
coverage/
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db

View File

@@ -1,177 +0,0 @@
# Analytics & Performance Tracking System
## Übersicht
Dieses Portfolio verwendet ein **GDPR-konformes Analytics-System** basierend auf **Umami** (self-hosted) mit erweitertem **Performance-Tracking**.
## Features
### ✅ GDPR-Konform
- **Keine Cookie-Banner** erforderlich
- **Keine personenbezogenen Daten** werden gesammelt
- **Anonymisierte Performance-Metriken**
- **Self-hosted** - vollständige Datenkontrolle
### 📊 Analytics Features
- **Page Views** - Seitenaufrufe
- **User Interactions** - Klicks, Formulare, Scroll-Verhalten
- **Error Tracking** - JavaScript-Fehler und unhandled rejections
- **Route Changes** - SPA-Navigation
### ⚡ Performance Tracking
- **Core Web Vitals**: LCP, FID, CLS, FCP, TTFB
- **Page Load Times** - Detaillierte Timing-Phasen
- **API Response Times** - Backend-Performance
- **Custom Performance Markers** - Spezifische Metriken
## Technische Implementierung
### 1. Umami Integration
```typescript
// Bereits in layout.tsx konfiguriert
<script
defer
src="https://umami.denshooter.de/script.js"
data-website-id="1f213877-deef-4238-8df1-71a5a3bcd142"
></script>
```
### 2. Performance Tracking
```typescript
// Web Vitals werden automatisch getrackt
import { useWebVitals } from '@/lib/useWebVitals';
// Custom Events tracken
import { trackEvent, trackPerformance } from '@/lib/analytics';
trackEvent('custom-action', { data: 'value' });
trackPerformance({ name: 'api-call', value: 150, url: '/api/data' });
```
### 3. Analytics Provider
```typescript
// Automatisches Tracking von:
// - Page Views
// - User Interactions (Klicks, Scroll, Forms)
// - Performance Metrics
// - Error Tracking
<AnalyticsProvider>
{children}
</AnalyticsProvider>
```
## Dashboard
### Performance Dashboard
- **Live Performance-Metriken** anzeigen
- **Core Web Vitals** mit Bewertungen (Good/Needs Improvement/Poor)
- **Toggle-Button** unten rechts auf der Website
- **Real-time Updates** der Performance-Daten
### Umami Dashboard
- **Standard Analytics** über deine Umami-Instanz
- **URL**: https://umami.denshooter.de
- **Website ID**: 1f213877-deef-4238-8df1-71a5a3bcd142
## Event-Typen
### Automatische Events
- `page-view` - Seitenaufrufe
- `click` - Benutzerklicks
- `form-submit` - Formular-Übermittlungen
- `scroll-depth` - Scroll-Tiefe (25%, 50%, 75%, 90%)
- `error` - JavaScript-Fehler
- `unhandled-rejection` - Unbehandelte Promise-Rejections
### Performance Events
- `web-vitals` - Core Web Vitals (LCP, FID, CLS, FCP, TTFB)
- `performance` - Custom Performance-Metriken
- `page-timing` - Detaillierte Page-Load-Phasen
- `api-call` - API-Response-Zeiten
### Custom Events
- `dashboard-toggle` - Performance Dashboard ein/aus
- `interaction` - Benutzerinteraktionen
## Datenschutz
### Was wird NICHT gesammelt:
- ❌ IP-Adressen
- ❌ User-IDs
- ❌ E-Mail-Adressen
- ❌ Personenbezogene Daten
- ❌ Cookies
### Was wird gesammelt:
- ✅ Anonymisierte Performance-Metriken
- ✅ Technische Browser-Informationen
- ✅ Seitenaufrufe (ohne persönliche Daten)
- ✅ Error-Logs (anonymisiert)
## Konfiguration
### Umami Setup
1. **Self-hosted Umami** auf deinem Server
2. **Website ID** in `layout.tsx` konfiguriert
3. **Script-URL** auf deine Umami-Instanz
### Performance Tracking
- **Automatisch aktiviert** durch `AnalyticsProvider`
- **Web Vitals** werden automatisch gemessen
- **Custom Events** über `trackEvent()` Funktion
## Monitoring
### Performance-Schwellenwerte
- **LCP**: ≤ 2.5s (Good), ≤ 4s (Needs Improvement), > 4s (Poor)
- **FID**: ≤ 100ms (Good), ≤ 300ms (Needs Improvement), > 300ms (Poor)
- **CLS**: ≤ 0.1 (Good), ≤ 0.25 (Needs Improvement), > 0.25 (Poor)
- **FCP**: ≤ 1.8s (Good), ≤ 3s (Needs Improvement), > 3s (Poor)
- **TTFB**: ≤ 800ms (Good), ≤ 1.8s (Needs Improvement), > 1.8s (Poor)
### Dashboard-Zugriff
- **Performance Dashboard**: Toggle-Button unten rechts
- **Umami Dashboard**: https://umami.denshooter.de
- **API Endpoint**: `/api/analytics` für Custom-Tracking
## Erweiterung
### Neue Events hinzufügen
```typescript
import { trackEvent } from '@/lib/analytics';
// Custom Event tracken
trackEvent('feature-usage', {
feature: 'contact-form',
success: true,
duration: 1500
});
```
### Performance-Metriken erweitern
```typescript
import { trackPerformance } from '@/lib/analytics';
// Custom Performance-Metrik
trackPerformance({
name: 'component-render',
value: renderTime,
url: window.location.pathname
});
```
## Troubleshooting
### Performance Dashboard nicht sichtbar
- Prüfe Browser-Konsole auf Fehler
- Stelle sicher, dass `AnalyticsProvider` in `layout.tsx` eingebunden ist
### Umami Events nicht sichtbar
- Prüfe Umami-Dashboard auf https://umami.denshooter.de
- Stelle sicher, dass Website ID korrekt ist
- Prüfe Browser-Netzwerk-Tab auf Umami-Requests
### Performance-Metriken fehlen
- Prüfe Browser-Konsole auf Performance Observer Fehler
- Stelle sicher, dass `useWebVitals` Hook aktiv ist
- Teste in verschiedenen Browsern

View File

@@ -1,226 +0,0 @@
# Automatisches Deployment System
## Übersicht
Dieses Portfolio verwendet ein **automatisches Deployment-System**, das bei jedem Git Push die Codebase prüft, den Container erstellt und startet.
## 🚀 Deployment-Skripte
### **1. Auto-Deploy (Vollständig)**
```bash
# Vollständiges automatisches Deployment
./scripts/auto-deploy.sh
# Oder mit npm
npm run auto-deploy
```
**Was passiert:**
- ✅ Git Status prüfen und uncommitted Changes committen
- ✅ Latest Changes pullen
- ✅ ESLint Linting
- ✅ Tests ausführen
- ✅ Next.js Build
- ✅ Docker Image erstellen
- ✅ Container stoppen/starten
- ✅ Health Check
- ✅ Cleanup alter Images
### **2. Quick-Deploy (Schnell)**
```bash
# Schnelles Deployment ohne Tests
./scripts/quick-deploy.sh
# Oder mit npm
npm run quick-deploy
```
**Was passiert:**
- ✅ Docker Image erstellen
- ✅ Container stoppen/starten
- ✅ Health Check
### **3. Manuelles Deployment**
```bash
# Manuelles Deployment mit Docker Compose
./scripts/deploy.sh
# Oder mit npm
npm run deploy
```
## 🔄 Automatisches Deployment
### **Git Hook Setup**
Das System verwendet einen Git Post-Receive Hook, der automatisch bei jedem Push ausgeführt wird:
```bash
# Hook ist bereits konfiguriert in:
.git/hooks/post-receive
```
### **Wie es funktioniert:**
1. **Git Push** → Hook wird ausgelöst
2. **Auto-Deploy Script** wird ausgeführt
3. **Vollständige Pipeline** läuft automatisch
4. **Deployment** wird durchgeführt
5. **Health Check** bestätigt Erfolg
## 📋 Deployment-Schritte
### **Automatisches Deployment:**
```bash
# 1. Code Quality Checks
git status --porcelain
git pull origin main
npm run lint
npm run test
# 2. Build Application
npm run build
# 3. Docker Operations
docker build -t portfolio-app:latest .
docker tag portfolio-app:latest portfolio-app:$(date +%Y%m%d-%H%M%S)
# 4. Deployment
docker stop portfolio-app || true
docker rm portfolio-app || true
docker run -d --name portfolio-app -p 3000:3000 portfolio-app:latest
# 5. Health Check
curl -f http://localhost:3000/api/health
# 6. Cleanup
docker system prune -f
```
## 🎯 Verwendung
### **Für Entwicklung:**
```bash
# Schnelles Deployment während der Entwicklung
npm run quick-deploy
```
### **Für Production:**
```bash
# Vollständiges Deployment mit Tests
npm run auto-deploy
```
### **Automatisch bei Push:**
```bash
# Einfach committen und pushen
git add .
git commit -m "Update feature"
git push origin main
# → Automatisches Deployment läuft
```
## 📊 Monitoring
### **Container Status:**
```bash
# Status prüfen
npm run monitor status
# Health Check
npm run monitor health
# Logs anzeigen
npm run monitor logs
```
### **Deployment Logs:**
```bash
# Deployment-Logs anzeigen
tail -f /var/log/portfolio-deploy.log
# Git-Deployment-Logs
tail -f /var/log/git-deploy.log
```
## 🔧 Konfiguration
### **Ports:**
- **Standard Port:** 3000
- **Backup Port:** 3001 (falls 3000 belegt)
### **Container:**
- **Name:** portfolio-app
- **Image:** portfolio-app:latest
- **Restart Policy:** unless-stopped
### **Logs:**
- **Deployment Logs:** `/var/log/portfolio-deploy.log`
- **Git Logs:** `/var/log/git-deploy.log`
## 🚨 Troubleshooting
### **Deployment schlägt fehl:**
```bash
# Logs prüfen
docker logs portfolio-app
# Container-Status prüfen
docker ps -a
# Manuell neu starten
npm run quick-deploy
```
### **Port bereits belegt:**
```bash
# Ports prüfen
lsof -i :3000
# Anderen Port verwenden
docker run -d --name portfolio-app -p 3001:3000 portfolio-app:latest
```
### **Tests schlagen fehl:**
```bash
# Tests lokal ausführen
npm run test
# Linting prüfen
npm run lint
# Build testen
npm run build
```
## 📈 Features
### **Automatische Features:**
-**Git Integration** - Automatisch bei Push
-**Code Quality** - Linting und Tests
-**Health Checks** - Automatische Verifikation
-**Rollback** - Alte Container werden gestoppt
-**Cleanup** - Alte Images werden entfernt
-**Logging** - Vollständige Deployment-Logs
### **Sicherheits-Features:**
-**Non-root Container**
-**Resource Limits**
-**Health Monitoring**
-**Error Handling**
-**Rollback bei Fehlern**
## 🎉 Vorteile
1. **Automatisierung** - Keine manuellen Schritte nötig
2. **Konsistenz** - Immer gleiche Deployment-Prozesse
3. **Sicherheit** - Tests vor jedem Deployment
4. **Monitoring** - Vollständige Logs und Health Checks
5. **Schnell** - Quick-Deploy für Entwicklung
6. **Zuverlässig** - Automatische Rollbacks bei Fehlern
## 📞 Support
Bei Problemen:
1. **Logs prüfen:** `tail -f /var/log/portfolio-deploy.log`
2. **Container-Status:** `npm run monitor status`
3. **Health Check:** `npm run monitor health`
4. **Manueller Neustart:** `npm run quick-deploy`

156
CLAUDE.md Normal file
View File

@@ -0,0 +1,156 @@
# CLAUDE.md - Portfolio Project Guide
## 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.
## 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 support (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, REST/GraphQL, 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
## Commands
```bash
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 test:e2e # Playwright E2E tests
```
## Project Structure
```
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
```
## Architecture Patterns
### 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
All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable.
### CMS Integration (Directus)
- REST/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 translation system (M2O to `languages`)
- Locale mapping: `en` -> `en-US`, `de` -> `de-DE`
### 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`
## Design System
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`.
## 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=...
```
## Conventions
- 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/<name>/route.ts`
3. Create a component in `app/components/<Name>.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

View File

@@ -1,272 +0,0 @@
# Portfolio Deployment Guide
## Übersicht
Dieses Portfolio verwendet ein **optimiertes CI/CD-System** mit Docker für Production-Deployment. Das System ist darauf ausgelegt, hohen Traffic zu bewältigen und automatische Tests vor dem Deployment durchzuführen.
## 🚀 Features
### ✅ **CI/CD Pipeline**
- **Automatische Tests** vor jedem Deployment
- **Security Scanning** mit Trivy
- **Multi-Architecture Docker Builds** (AMD64 + ARM64)
- **Health Checks** und Deployment-Verifikation
- **Automatische Cleanup** alter Images
### ⚡ **Performance-Optimierungen**
- **Multi-Stage Docker Build** für kleinere Images
- **Nginx Load Balancer** mit Caching
- **Gzip Compression** und optimierte Headers
- **Rate Limiting** für API-Endpoints
- **Resource Limits** für Container
### 🔒 **Sicherheit**
- **Non-root User** im Container
- **Security Headers** (HSTS, CSP, etc.)
- **SSL/TLS Termination** mit Nginx
- **Vulnerability Scanning** in CI/CD
## 📁 Dateistruktur
```
├── .github/workflows/
│ └── ci-cd.yml # CI/CD Pipeline
├── scripts/
│ ├── deploy.sh # Deployment-Skript
│ └── monitor.sh # Monitoring-Skript
├── docker-compose.prod.yml # Production Docker Compose
├── nginx.conf # Nginx Konfiguration
├── Dockerfile # Optimiertes Dockerfile
└── env.example # Environment Template
```
## 🛠️ Setup
### 1. **Environment Variables**
```bash
# Kopiere die Beispiel-Datei
cp env.example .env
# Bearbeite die .env Datei mit deinen Werten
nano .env
```
### 2. **GitHub Secrets & Variables**
Konfiguriere in deinem GitHub Repository:
**Secrets:**
- `GITHUB_TOKEN` (automatisch verfügbar)
- `GHOST_API_KEY`
- `MY_PASSWORD`
- `MY_INFO_PASSWORD`
**Variables:**
- `NEXT_PUBLIC_BASE_URL`
- `GHOST_API_URL`
- `MY_EMAIL`
- `MY_INFO_EMAIL`
### 3. **SSL-Zertifikate**
```bash
# Erstelle SSL-Verzeichnis
mkdir -p ssl
# Kopiere deine SSL-Zertifikate
cp your-cert.pem ssl/cert.pem
cp your-key.pem ssl/key.pem
```
## 🚀 Deployment
### **Automatisches Deployment**
Das System deployt automatisch bei Push auf den `production` Branch:
```bash
# Code auf production Branch pushen
git push origin production
```
### **Manuelles Deployment**
```bash
# Lokales Deployment
./scripts/deploy.sh production
# Oder mit npm
npm run deploy
```
### **Docker Commands**
```bash
# Container starten
npm run docker:compose
# Container stoppen
npm run docker:down
# Health Check
npm run health
```
## 📊 Monitoring
### **Container Status**
```bash
# Status anzeigen
./scripts/monitor.sh status
# Oder mit npm
npm run monitor status
```
### **Health Check**
```bash
# Application Health
./scripts/monitor.sh health
# Oder direkt
curl http://localhost:3000/api/health
```
### **Logs anzeigen**
```bash
# Letzte 50 Zeilen
./scripts/monitor.sh logs 50
# Live-Logs folgen
./scripts/monitor.sh logs 100
```
### **Metriken**
```bash
# Detaillierte Metriken
./scripts/monitor.sh metrics
```
## 🔧 Wartung
### **Container neustarten**
```bash
./scripts/monitor.sh restart
```
### **Cleanup**
```bash
# Docker-Ressourcen bereinigen
./scripts/monitor.sh cleanup
```
### **Updates**
```bash
# Neues Image pullen und deployen
./scripts/deploy.sh production
```
## 📈 Performance-Tuning
### **Nginx Optimierungen**
- **Gzip Compression** aktiviert
- **Static Asset Caching** (1 Jahr)
- **API Rate Limiting** (10 req/s)
- **Load Balancing** bereit für Skalierung
### **Docker Optimierungen**
- **Multi-Stage Build** für kleinere Images
- **Non-root User** für Sicherheit
- **Health Checks** für automatische Recovery
- **Resource Limits** (512MB RAM, 0.5 CPU)
### **Next.js Optimierungen**
- **Standalone Output** für Docker
- **Image Optimization** (WebP, AVIF)
- **CSS Optimization** aktiviert
- **Package Import Optimization**
## 🚨 Troubleshooting
### **Container startet nicht**
```bash
# Logs prüfen
./scripts/monitor.sh logs
# Status prüfen
./scripts/monitor.sh status
# Neustarten
./scripts/monitor.sh restart
```
### **Health Check schlägt fehl**
```bash
# Manueller Health Check
curl -v http://localhost:3000/api/health
# Container-Logs prüfen
docker compose -f docker-compose.prod.yml logs portfolio
```
### **Performance-Probleme**
```bash
# Resource-Usage prüfen
./scripts/monitor.sh metrics
# Nginx-Logs prüfen
docker compose -f docker-compose.prod.yml logs nginx
```
### **SSL-Probleme**
```bash
# SSL-Zertifikate prüfen
openssl x509 -in ssl/cert.pem -text -noout
# Nginx-Konfiguration testen
docker compose -f docker-compose.prod.yml exec nginx nginx -t
```
## 📋 CI/CD Pipeline
### **Workflow-Schritte**
1. **Test** - Linting, Tests, Build
2. **Security** - Trivy Vulnerability Scan
3. **Build** - Multi-Arch Docker Image
4. **Deploy** - Automatisches Deployment
### **Trigger**
- **Push auf `main`** - Build nur
- **Push auf `production`** - Build + Deploy
- **Pull Request** - Test + Security
### **Monitoring**
- **GitHub Actions** - Pipeline-Status
- **Container Health** - Automatische Checks
- **Resource Usage** - Monitoring-Skript
## 🔄 Skalierung
### **Horizontal Scaling**
```yaml
# In nginx.conf - weitere Backend-Server hinzufügen
upstream portfolio_backend {
least_conn;
server portfolio:3000 max_fails=3 fail_timeout=30s;
server portfolio-2:3000 max_fails=3 fail_timeout=30s;
server portfolio-3:3000 max_fails=3 fail_timeout=30s;
}
```
### **Vertical Scaling**
```yaml
# In docker-compose.prod.yml - Resource-Limits erhöhen
deploy:
resources:
limits:
memory: 1G
cpus: '1.0'
```
## 📞 Support
Bei Problemen:
1. **Logs prüfen**: `./scripts/monitor.sh logs`
2. **Status prüfen**: `./scripts/monitor.sh status`
3. **Health Check**: `./scripts/monitor.sh health`
4. **Container neustarten**: `./scripts/monitor.sh restart`

View File

@@ -1,239 +0,0 @@
# 🚀 Development Environment Setup
This document explains how to set up and use the development environment for the portfolio project.
## ✨ Features
- **Automatic Database Setup**: PostgreSQL and Redis start automatically
- **Hot Reload**: Next.js development server with hot reload
- **Database Integration**: Real database integration for email management
- **Modern Admin Dashboard**: Completely redesigned admin interface
- **Minimal Setup**: Only essential services for fast development
## 🛠️ Quick Start
### Prerequisites
- Node.js 18+
- Docker & Docker Compose
- npm or yarn
### 1. Install Dependencies
```bash
npm install
```
### 2. Start Development Environment
#### Option A: Full Development Environment (with Docker)
```bash
npm run dev
```
This single command will:
- Start PostgreSQL database
- Start Redis cache
- Start Next.js development server
- Set up all environment variables
#### Option B: Simple Development Mode (without Docker)
```bash
npm run dev:simple
```
This starts only the Next.js development server without Docker services. Use this if you don't have Docker installed or want a faster startup.
### 3. Access Services
- **Portfolio**: http://localhost:3000
- **Admin Dashboard**: http://localhost:3000/manage
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
## 📧 Email Testing
The development environment supports email functionality:
1. Send emails through the contact form or admin panel
2. Emails are sent directly (configure SMTP in production)
3. Check console logs for email debugging
## 🗄️ Database
### Development Database
- **Host**: localhost:5432
- **Database**: portfolio_dev
- **User**: portfolio_user
- **Password**: portfolio_dev_pass
### Database Commands
```bash
# Generate Prisma client
npm run db:generate
# Push schema changes
npm run db:push
# Seed database with sample data
npm run db:seed
# Open Prisma Studio
npm run db:studio
# Reset database
npm run db:reset
```
## 🎨 Admin Dashboard
The new admin dashboard includes:
- **Overview**: Statistics and recent activity
- **Projects**: Manage portfolio projects
- **Emails**: Handle contact form submissions with beautiful templates
- **Analytics**: View performance metrics
- **Settings**: Import/export functionality
### Email Templates
Three beautiful email templates are available:
1. **Welcome Template** (Green): Friendly greeting with portfolio links
2. **Project Template** (Purple): Professional project discussion response
3. **Quick Template** (Orange): Fast acknowledgment response
## 🔧 Environment Variables
Create a `.env.local` file:
```env
# Development Database
DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public"
# Redis
REDIS_URL="redis://localhost:6379"
# Email (for production)
MY_EMAIL=contact@dk0.dev
MY_PASSWORD=your-email-password
# Application
NEXT_PUBLIC_BASE_URL=http://localhost:3000
NODE_ENV=development
```
## 🛑 Stopping the Environment
Use Ctrl+C to stop all services, or:
```bash
# Stop Docker services only
npm run docker:dev:down
```
## 🐳 Docker Commands
```bash
# Start only database services
npm run docker:dev
# Stop database services
npm run docker:dev:down
# View logs
docker compose -f docker-compose.dev.minimal.yml logs -f
```
## 📁 Project Structure
```
├── docker-compose.dev.minimal.yml # Minimal development services
├── scripts/
│ ├── dev-minimal.js # Main development script
│ ├── dev-simple.js # Simple development script
│ ├── setup-database.js # Database setup script
│ └── init-db.sql # Database initialization
├── app/
│ ├── admin/ # Admin dashboard
│ ├── api/
│ │ ├── contacts/ # Contact management API
│ │ └── email/ # Email sending API
│ └── components/
│ ├── ModernAdminDashboard.tsx
│ ├── EmailManager.tsx
│ └── EmailResponder.tsx
└── prisma/
└── schema.prisma # Database schema
```
## 🚨 Troubleshooting
### Docker Compose Not Found
If you get the error `spawn docker compose ENOENT`:
```bash
# Try the simple dev mode instead
npm run dev:simple
# Or install Docker Desktop
# Download from: https://www.docker.com/products/docker-desktop
```
### Port Conflicts
If ports are already in use:
```bash
# Check what's using the ports
lsof -i :3000
lsof -i :5432
lsof -i :6379
# Kill processes if needed
kill -9 <PID>
```
### Database Connection Issues
```bash
# Restart database services
npm run docker:dev:down
npm run docker:dev
# Check database status
docker compose -f docker-compose.dev.minimal.yml ps
```
### Email Not Working
1. Verify environment variables
2. Check browser console for errors
3. Ensure SMTP is configured for production
## 🎯 Production Deployment
For production deployment, use:
```bash
npm run build
npm run start
```
The production environment uses the production Docker Compose configuration.
## 📝 Notes
- The development environment automatically creates sample data
- Database changes are persisted in Docker volumes
- Hot reload works for all components and API routes
- Minimal setup for fast development startup
## 🔗 Links
- **Portfolio**: https://dk0.dev
- **Admin**: https://dk0.dev/manage
- **GitHub**: https://github.com/denniskonkol/portfolio

269
DIRECTUS_CHECKLIST.md Normal file
View File

@@ -0,0 +1,269 @@
# Directus CMS Eingabe-Checkliste
## Collections und Struktur
Du hast zwei Collections in Directus:
1. **messages** kurze UI-Texte (Keys mit Werten)
2. **content_pages** längere Abschnitte (Slug mit Rich Text)
---
## Collection: messages
Alle folgenden Einträge in Directus anlegen. Format:
| key | locale | value |
### Navigation & Header
```
nav.home | en | Home
nav.home | de | Startseite
nav.about | en | About
nav.about | de | Über mich
nav.projects | en | Projects
nav.projects | de | Projekte
nav.contact | en | Contact
nav.contact | de | Kontakt
```
### Footer
```
footer.role | en | Software Engineer
footer.role | de | Software Engineer
footer.madeIn | en | Made in Germany
footer.madeIn | de | Made in Germany
footer.legalNotice | en | Legal notice
footer.legalNotice | de | Impressum
footer.privacyPolicy | en | Privacy policy
footer.privacyPolicy | de | Datenschutz
footer.privacySettings| en | Privacy settings
footer.privacySettings| de | Datenschutz-Einstellungen
footer.privacySettingsTitle | en | Show privacy settings banner again
footer.privacySettingsTitle | de | Datenschutz-Banner wieder anzeigen
footer.builtWith | en | Built with
footer.builtWith | de | Built with
```
### Home Hero
```
home.hero.features.f1 | en | Next.js & Flutter
home.hero.features.f1 | de | Next.js & Flutter
home.hero.features.f2 | en | Docker Swarm & CI/CD
home.hero.features.f2 | de | Docker Swarm & CI/CD
home.hero.features.f3 | en | Self-Hosted Infrastructure
home.hero.features.f3 | de | Self-Hosted Infrastruktur
```
### Home About
```
home.about.title | en | About Me
home.about.title | de | Über mich
home.about.techStackTitle | en | My Tech Stack
home.about.techStackTitle | de | Mein Tech Stack
home.about.hobbiesTitle | en | When I'm Not Coding
home.about.hobbiesTitle | de | Wenn ich nicht code
home.about.currentlyReading.title | en | Currently Reading
home.about.currentlyReading.title | de | Aktuell am Lesen
home.about.currentlyReading.progress | en | Progress
home.about.currentlyReading.progress | de | Fortschritt
```
### Home Projects (List)
```
home.projects.title | en | Selected Works
home.projects.title | de | Ausgewählte Projekte
home.projects.subtitle | en | A collection of projects I've worked on...
home.projects.subtitle | de | Eine Auswahl an Projekten, an denen ich gearbeitet habe...
home.projects.featured | en | Featured
home.projects.featured | de | Hervorgehoben
home.projects.viewAll | en | View All Projects
home.projects.viewAll | de | Alle Projekte ansehen
```
### Home Contact
```
home.contact.title | en | Contact Me
home.contact.title | de | Kontakt
home.contact.subtitle | en | Interested in working together...
home.contact.subtitle | de | Du willst zusammenarbeiten...
home.contact.getInTouch | en | Get In Touch
home.contact.getInTouch | de | Melde dich
home.contact.getInTouchBody | en | I'm always available to discuss...
home.contact.getInTouchBody | de | Ich bin immer offen für neue Chancen...
home.contact.info.email | en | Email
home.contact.info.email | de | E-Mail
home.contact.info.location | en | Location
home.contact.info.location | de | Ort
home.contact.info.locationValue | en | Osnabrück, Germany
home.contact.info.locationValue | de | Osnabrück, Deutschland
```
### Common
```
common.backToHome | en | Back to Home
common.backToHome | de | Zurück zur Startseite
common.backToProjects | en | Back to Projects
common.backToProjects | de | Zurück zu den Projekten
common.viewAllProjects | en | View All Projects
common.viewAllProjects | de | Alle Projekte ansehen
common.loading | en | Loading...
common.loading | de | Lädt...
```
### Projects List
```
projects.list.title | en | My Projects
projects.list.title | de | Meine Projekte
projects.list.intro | en | Explore my portfolio...
projects.list.intro | de | Stöbere durch mein Portfolio...
projects.list.searchPlaceholder | en | Search projects...
projects.list.searchPlaceholder | de | Projekte durchsuchen...
projects.list.all | en | All
projects.list.all | de | Alle
projects.list.noResults | en | No projects found...
projects.list.noResults | de | Keine Projekte passen...
projects.list.clearFilters | en | Clear filters
projects.list.clearFilters | de | Filter zurücksetzen
```
### Projects Detail
```
projects.detail.links | en | Project Links
projects.detail.links | de | Projektlinks
projects.detail.liveDemo | en | Live Demo
projects.detail.liveDemo | de | Live-Demo
projects.detail.liveNotAvailable | en | Live demo not available
projects.detail.liveNotAvailable | de | Keine Live-Demo verfügbar
projects.detail.viewSource | en | View Source
projects.detail.viewSource | de | Quellcode ansehen
projects.detail.techStack | en | Tech Stack
projects.detail.techStack | de | Tech-Stack
```
### Consent & Privacy
```
consent.title | en | Privacy settings
consent.title | de | Datenschutz-Einstellungen
consent.description | en | We use optional services...
consent.description | de | Wir nutzen optionale Dienste...
consent.essential | en | Essential
consent.essential | de | Essentiell
consent.analytics | en | Analytics
consent.analytics | de | Analytics
consent.chat | en | Chatbot
consent.chat | de | Chatbot
consent.alwaysOn | en | Always on
consent.alwaysOn | de | Immer aktiv
consent.acceptAll | en | Accept all
consent.acceptAll | de | Alles akzeptieren
consent.acceptSelected | en | Accept selected
consent.acceptSelected | de | Auswahl akzeptieren
consent.rejectAll | en | Reject all
consent.rejectAll | de | Alles ablehnen
consent.hide | en | Hide
consent.hide | de | Ausblenden
```
---
## Collection: content_pages
Diese sind für **längere Inhalte**. Nutze den Ric-Text-Editor in Directus oder Markdown.
### Home Hero (langere Beschreibung)
- **slug**: home-hero
- **locale**: en / de
- **title** (optional): Hero Section Description
- **content**: Längerer Text/Rich Text (ersetzen die kurze beschreibung)
Beispiel EN:
> "I'm a passionate software engineer and self-hoster from Osnabrück, Germany. I build full-stack web applications with Next.js, create mobile solutions with Flutter, and love exploring DevOps. I run my own infrastructure and automate deployments with CI/CD."
Beispiel DE:
> "Ich bin ein leidenschaftlicher Softwareentwickler und Self-Hoster aus Osnabrück. Ich entwickle Full-Stack Web-Apps mit Next.js, mobile Apps mit Flutter und bin begeistert von DevOps. Ich betreibe meine eigene Infrastruktur und automatisiere Deployments."
### Home About (längere Inhalte)
- **slug**: home-about
- **locale**: en / de
- **content**: Längerer Fließtext über mich
### Home Projects Intro
- **slug**: home-projects
- **locale**: en / de
- **content**: Intro-Text vor der Projekt-Liste
### Home Contact Intro
- **slug**: home-contact
- **locale**: en / de
- **content**: Intro vor dem Kontakt-Formular
---
## Wie du es in Directus eingeben kannst:
### Schritt 1: messages Collection
1. Gehe in Directus → **messages**.
2. Klick "Create New" (oder "+").
3. Füll aus:
- **key**: z. B. "nav.home"
- **locale**: Dropdown → "en" oder "de"
- **value**: Der Text (z. B. "Home")
4. Speichern. Wiederholen für alle Keys oben.
### Schritt 2: content_pages Collection
1. Gehe in Directus → **content_pages**.
2. Klick "Create New".
3. Füll aus:
- **slug**: z. B. "home-hero"
- **locale**: "en" oder "de"
- **title** (optional): "Hero Section" oder leer
- **content**: Markdown/Rich Text eingeben
4. Speichern. Wiederholen für andere Seiten.
---
## Im Code: Texte nutzen
### Kurze Keys (aus messages):
```tsx
import { getLocalizedMessage } from '@/lib/i18n-loader';
const text = await getLocalizedMessage('nav.home', locale);
// text = "Home" (oder fallback aus JSON)
```
### Längere Inhalte (aus content_pages):
```tsx
import { getLocalizedContent } from '@/lib/i18n-loader';
const page = await getLocalizedContent('home-hero', locale);
// page.content = "Längerer Fließtext..."
```
---
## Quick-Test:
1. Lege in Directus **einen** Key in messages an:
- key: "test"
- locale: "en"
- value: "Hello from Directus"
2. Im Code:
```tsx
const text = await getLocalizedMessage('test', 'en');
console.log(text); // sollte "Hello from Directus" loggen
```
3. Wenn das funktioniert: Alle anderen Keys eintragen!
---
## Hinweise:
- **Keys** sollten mit `.` strukturiert sein (z. B. `nav.home`, `home.about.title`).
- **Locale** ist immer "en" oder "de" (enum).
- **Fallback**: Wenn ein Key in Directus fehlt, nutzt der Code die `messages/*.json` Dateien.
- **Caching**: Texte werden 5 Minuten gecacht. Um Cache zu leeren: `clearI18nCache()` im Code oder Server restart.
- **Rich Text**: Im `content_pages` Feld kannst du Markdown oder den Rich-Text-Editor nutzen.
Viel Spaß! 🚀

View File

@@ -1,13 +1,12 @@
# Multi-stage build for optimized production image
FROM node:20 AS base
FROM node:25 AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install dependencies based on the preferred package manager
# Copy package files first for better caching
COPY package.json package-lock.json* ./
RUN npm ci --only=production && npm cache clean --force
@@ -19,22 +18,38 @@ WORKDIR /app
COPY package.json package-lock.json* ./
# Install all dependencies (including dev dependencies for build)
RUN npm ci
# Use npm ci with cache mount for faster builds
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy source code
COPY . .
# Copy Prisma schema first (for better caching)
COPY prisma ./prisma
# Install type definitions for react-responsive-masonry and node-fetch
RUN npm install --save-dev @types/react-responsive-masonry @types/node-fetch
# Generate Prisma client
# Generate Prisma client (cached if schema unchanged)
RUN npx prisma generate
# Copy source code (this invalidates cache when code changes)
COPY . .
# Build the application
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Verify standalone output was created and show structure for debugging
RUN if [ ! -d .next/standalone ]; then \
echo "ERROR: .next/standalone directory not found!"; \
echo "Contents of .next directory:"; \
ls -la .next/ || true; \
echo "Checking if standalone exists in different location:"; \
find .next -name "standalone" -type d || true; \
exit 1; \
fi && \
echo "✅ Standalone output found" && \
ls -la .next/standalone/ && \
echo "Standalone structure:" && \
find .next/standalone -type f -name "server.js" || echo "server.js not found in standalone"
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
@@ -42,6 +57,9 @@ WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Install curl for health checks
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -49,21 +67,30 @@ RUN adduser --system --uid 1001 nextjs
# Copy the built application
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
# Copy standalone output (contains server.js and all dependencies)
# The standalone output structure is: .next/standalone/ (not .next/standalone/app/)
# Next.js creates: .next/standalone/server.js, .next/standalone/.next/, .next/standalone/node_modules/
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Create cache directories with correct permissions AFTER copying standalone
RUN mkdir -p .next/cache/fetch-cache .next/cache/images && \
chown -R nextjs:nodejs .next/cache
# Copy Prisma files
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Copy environment file
COPY --from=builder /app/.env* ./
# Create scripts directory and copy start script AFTER standalone to ensure it's not overwritten
RUN mkdir -p scripts && chown nextjs:nodejs scripts
COPY --from=builder --chown=nextjs:nodejs /app/scripts/start-with-migrate.js ./scripts/start-with-migrate.js
# Note: Environment variables should be passed via docker-compose or runtime environment
# DO NOT copy .env files into the image for security reasons
USER nextjs
@@ -76,4 +103,4 @@ ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
CMD ["node", "server.js"]
CMD ["node", "scripts/start-with-migrate.js"]

34
GEMINI.md Normal file
View File

@@ -0,0 +1,34 @@
# Gemini CLI: Project Context & Engineering Mandates
## Project Identity
- **Name:** Dennis Konkol Portfolio (dk0.dev)
- **Aesthetic:** "Liquid Editorial Bento" (Premium, minimalistisch, hoch-typografisch).
- **Core Palette:** Creme (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`), Sky, Purple.
## Tech Stack
- **Framework:** Next.js 15 (App Router), Tailwind CSS 3.4.
- **CMS:** Directus (primär für Texte, Hobbies, Tech-Stack, Projekte).
- **Database:** PostgreSQL (Prisma) als lokaler Cache/Mirror für Projekte.
- **Animations:** Framer Motion (bevorzugt für alle Übergänge).
- **i18n:** `next-intl` (Locales: `en`, `de`).
## Engineering Guidelines (Mandates)
### 1. UI Components
- **Bento Grid:** Neue Features sollten immer in das bestehende Grid integriert werden. Keine schwebenden Overlays.
- **Skeletons:** Jede asynchrone Komponente benötigt einen passenden `Skeleton` Ladezustand.
- **Typography:** Headlines immer uppercase, tracking-tighter, mit Akzent-Punkt am Ende.
### 2. Implementation Rules
- **TypeScript:** Keine `any`. Nutze bestehende Interfaces in `lib/directus.ts` oder `app/_ui/`.
- **Resilience:** Alle API-Calls müssen Fehler abfangen und sinnvolle Fallbacks (oder Skeletons) anzeigen.
- **Next.js Standalone:** Das Projekt nutzt den `standalone` Build-Mode. Docker-Builds müssen immer verifiziert werden.
### 3. Agent Instructions
- **Codebase Investigator:** Nutze dieses Tool für Architektur-Fragen.
- **Testing:** Führe `npm run test` nach UI-Änderungen aus. Achte auf JSDOM-Einschränkungen (Mocking von `window.matchMedia` und `IntersectionObserver`).
- **CMS First:** Texte sollten nach Möglichkeit aus der `messages` Collection in Directus kommen, nicht hartcodiert werden.
## Current State
- **Branch:** `dev` (pushed)
- **Status:** Design Overhaul abgeschlossen, Build stabil, Docker verifiziert.

View File

@@ -1,56 +0,0 @@
# Gitea Secrets Setup
Um die GitHub Actions Workflows korrekt zu verwenden, müssen die folgenden Secrets in deinem Gitea Repository konfiguriert werden:
## Secrets konfigurieren
1. Gehe zu deinem Repository in Gitea
2. Klicke auf **Settings****Secrets**
3. Füge die folgenden Secrets hinzu:
### Erforderliche Secrets:
| Secret Name | Beschreibung | Beispiel |
|-------------|--------------|----------|
| `NEXT_PUBLIC_BASE_URL` | Die öffentliche URL deiner Website | `https://dk0.dev` |
| `MY_EMAIL` | Haupt-Email-Adresse für Kontaktformular | `contact@dk0.dev` |
| `MY_INFO_EMAIL` | Info-Email-Adresse | `info@dk0.dev` |
| `MY_PASSWORD` | Passwort für Haupt-Email | `dein_email_passwort` |
| `MY_INFO_PASSWORD` | Passwort für Info-Email | `dein_info_email_passwort` |
| `ADMIN_BASIC_AUTH` | Admin-Basic-Auth für geschützte Bereiche | `admin:dein_sicheres_passwort` |
## Docker Compose Setup
Die Workflows verwenden jetzt `docker-compose.workflow.yml` für eine vollständige Service-Konfiguration:
- **PostgreSQL**: Datenbank für die Anwendung
- **Redis**: Caching und Session-Management
- **Portfolio App**: Die Hauptanwendung
## Netzwerk-Konfiguration
Die Services sind im `portfolio_net` Netzwerk konfiguriert, damit sie miteinander kommunizieren können.
## Health Checks
Alle Services haben Health Checks konfiguriert:
- PostgreSQL: `pg_isready`
- Redis: `redis-cli ping`
- Portfolio App: HTTP Health Check auf `/api/health`
## Troubleshooting
Falls die Workflows fehlschlagen:
1. **Secrets prüfen**: Stelle sicher, dass alle Secrets korrekt konfiguriert sind
2. **Netzwerk prüfen**: Überprüfe, ob das `portfolio_net` Netzwerk existiert
3. **Ports prüfen**: Stelle sicher, dass Port 3000 frei ist
4. **Logs prüfen**: Schaue in die Container-Logs für Fehlermeldungen
```bash
# Container-Logs anzeigen
docker-compose -f docker-compose.workflow.yml logs
# Services-Status prüfen
docker-compose -f docker-compose.workflow.yml ps
```

View File

@@ -1,3 +1,8 @@
# Quick links
- **Ops / setup / deployment / testing**: `docs/OPERATIONS.md`
- **Locale System & Translations**: `docs/LOCALE_SYSTEM.md`
# Dennis Konkol Portfolio - Modern Dark Theme
Ein modernes, responsives Portfolio mit dunklem Design, coolen Animationen und einem integrierten Admin-Dashboard.
@@ -48,8 +53,10 @@ npm run start # Production Server
## 📖 Dokumentation
- [Development Setup](DEV-SETUP.md) - Detaillierte Setup-Anleitung
- [Deployment Guide](DEPLOYMENT.md) - Production Deployment
- [Deployment Setup](DEPLOYMENT_SETUP.md) - Production Deployment
- [Analytics](ANALYTICS.md) - Analytics und Performance
- [CMS Guide](docs/CMS_GUIDE.md) - Inhalte/Sprachen pflegen (Rich Text)
- [Testing & Deployment](docs/TESTING_AND_DEPLOYMENT.md) - Branches → Container → Domains
## 🔗 Links

View File

@@ -1,105 +0,0 @@
# Secrets Verification Guide
## Wie du überprüfst, ob Secrets korrekt geladen werden
### 1. **Debug-Workflow ausführen**
Ich habe einen speziellen Debug-Workflow erstellt (`.gitea/workflows/debug-secrets.yml`):
1. Gehe zu deinem Repository in Gitea
2. Klicke auf **Actions****Debug Secrets**
3. Klicke auf **Run workflow****Run workflow**
4. Der Workflow wird dir zeigen:
- ✅ Welche Secrets gesetzt sind
- ❌ Welche Secrets fehlen
- 🔐 Ob die Formate korrekt sind
### 2. **Secrets in Gitea überprüfen**
**Manuell in Gitea:**
1. Gehe zu **Settings****Secrets**
2. Überprüfe, ob alle Secrets vorhanden sind:
- `NEXT_PUBLIC_BASE_URL`
- `MY_EMAIL`
- `MY_INFO_EMAIL`
- `MY_PASSWORD`
- `MY_INFO_PASSWORD`
- `ADMIN_BASIC_AUTH`
### 3. **Workflow-Logs überprüfen**
Nach dem Ausführen eines Workflows:
1. Gehe zu **Actions****Workflow runs**
2. Klicke auf den neuesten Run
3. Schaue dir die Logs an, besonders:
- "Verify secrets before deployment"
- "Verify container environment"
### 4. **Container direkt überprüfen**
Falls der Container läuft, kannst du ihn direkt überprüfen:
```bash
# Container-Environment anzeigen
docker exec portfolio-app env | grep -E "(NEXT_PUBLIC_BASE_URL|MY_EMAIL|ADMIN_BASIC_AUTH)"
# Container-Logs anzeigen
docker logs portfolio-app
# Services-Status prüfen
docker-compose -f docker-compose.workflow.yml ps
```
### 5. **Häufige Probleme**
**❌ Secret nicht gesetzt:**
- Gehe zu Gitea → Settings → Secrets
- Füge das fehlende Secret hinzu
**❌ Falsches Format:**
- `NEXT_PUBLIC_BASE_URL`: Muss mit `http://` oder `https://` beginnen
- `MY_EMAIL`: Muss eine gültige Email-Adresse sein
- `ADMIN_BASIC_AUTH`: Muss Format `username:password` haben
**❌ Container kann nicht starten:**
- Überprüfe die Docker-Logs
- Stelle sicher, dass alle Services (PostgreSQL, Redis) laufen
### 6. **Test-Schritte**
1. **Führe den Debug-Workflow aus**
2. **Überprüfe die Ausgabe**
3. **Korrigiere fehlende/falsche Secrets**
4. **Führe einen normalen Workflow aus**
5. **Überprüfe die Container-Environment**
### 7. **Erwartete Ausgabe**
**Bei korrekten Secrets:**
```
✅ NEXT_PUBLIC_BASE_URL: Set (length: 15)
✅ MY_EMAIL: Set (length: 20)
✅ MY_INFO_EMAIL: Set (length: 18)
✅ MY_PASSWORD: Set (length: 12)
✅ MY_INFO_PASSWORD: Set (length: 12)
✅ ADMIN_BASIC_AUTH: Set (length: 15)
```
**Bei fehlenden Secrets:**
```
❌ NEXT_PUBLIC_BASE_URL: Not set
❌ MY_EMAIL: Not set
```
### 8. **Schnelltest**
Führe diesen Befehl aus, um schnell zu testen:
```bash
# Debug-Workflow manuell ausführen
curl -X POST "https://your-gitea-instance.com/api/v1/repos/your-username/portfolio/actions/workflows/debug-secrets.yml/dispatches" \
-H "Authorization: token YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ref":"main"}'
```

42
SESSION_SUMMARY.md Normal file
View File

@@ -0,0 +1,42 @@
# Session Summary - February 16, 2026
## 🛡️ Security & Technical Fixes
- **CSP Improvements:** Added `images.unsplash.com`, `*.dk0.dev`, and `localhost` to `img-src` and `connect-src`.
- **Worker Support:** Enabled `worker-src 'self' blob:;` for dynamic features.
- **Source Map Suppression:** Configured Webpack to ignore 404 errors for `framer-motion` and `LayoutGroupContext` source maps in development.
- **Project Filtering:** Unified the projects API to use Directus as the "Single Source of Truth," strictly enforcing the `published` status.
## 🎨 UI/UX Enhancements (Liquid Editorial Bento)
- **Hero Section:**
- Stabilized the hero photo (removed floating animation).
- Fixed edge-clipping by increasing the border/padding.
- Removed redundant social buttons for a cleaner entry.
- **Activity Feed:**
- Full localization (DE/EN).
- Added a rotating cycle of CS-related quotes (Dijkstra, etc.) including CMS quotes.
- Redesigned Music UI with Spotify-themed branding (`#1DB954`), improved contrast, and animated frequency bars.
- **Contact Area:**
- Redesigned into a unified "Connect" Bento box.
- High-typography list style for Email, GitHub, LinkedIn, and Location.
- **Hobbies:**
- Added personalized descriptions reflecting interests like Analog Photography, Astronomy, and Traveling.
- Switched to a 4-column layout for better spatial balance.
## 🚀 New Features
- **Snippets System ("The Lab"):**
- New Directus collection and API endpoint for technical notes.
- Interactive Bento-modals with code syntax highlighting and copy-to-clipboard functionality.
- Dedicated `/snippets` overview page.
- Implemented "Featured" logic to control visibility on the home page.
- **Redesigned 404 Page:**
- Completely rebuilt in the Editorial Bento style with clear navigation paths.
- **Visual Finish:**
- Added a subtle, animated CSS-based Grain/Noise overlay.
- Implemented smooth Page Transitions using Framer Motion.
## 💻 Hardware Setup ("My Gear")
- Added a dedicated Bento card showing current dev setup:
- MacBook Pro M4 Pro (24GB RAM).
- PC: Ryzen 7 3800XT / RTX 3080.
- Server: IONOS Cloud & Raspberry Pi 4.
- Dual MSI 164Hz Curved Monitors.

195
STAGING_SETUP.md Normal file
View File

@@ -0,0 +1,195 @@
# 🚀 Staging Environment Setup
## Overview
You now have **two separate Docker stacks**:
1. **Staging** - Deploys automatically on `dev` or `main` branch
- Port: `3002`
- Container: `portfolio-app-staging`
- Database: `portfolio_staging_db` (port 5433)
- Redis: `portfolio-redis-staging` (port 6380)
- URL: `https://staging.dk0.dev` (or `http://localhost:3002`)
2. **Production** - Deploys automatically on `production` branch
- Port: `3000`
- Container: `portfolio-app`
- Database: `portfolio_db` (port 5432)
- Redis: `portfolio-redis` (port 6379)
- URL: `https://dk0.dev`
## How It Works
### Automatic Staging Deployment
When you push to `dev` or `main` branch:
1. ✅ Tests run
2. ✅ Docker image is built and tagged as `staging`
3. ✅ Staging stack deploys automatically
4. ✅ Available on port 3002
### Automatic Production Deployment
When you merge to `production` branch:
1. ✅ Tests run
2. ✅ Docker image is built and tagged as `production`
3.**Zero-downtime deployment** (blue-green)
4. ✅ Health checks before switching
5. ✅ Rollback if health check fails
6. ✅ Available on port 3000
## Safety Features
### Production Deployment Safety
-**Zero-downtime**: New container starts before old one stops
-**Health checks**: Verifies new container is healthy before switching
-**Automatic rollback**: If health check fails, old container stays running
-**Separate networks**: Staging and production are completely isolated
-**Different ports**: No port conflicts
-**Separate databases**: Staging data doesn't affect production
### Staging Deployment
-**Non-blocking**: Staging can fail without affecting production
-**Isolated**: Completely separate from production
-**Safe to test**: Break staging without breaking production
## Ports Used
| Service | Staging | Production |
|---------|---------|------------|
| App | 3002 | 3000 |
| PostgreSQL | 5434 | 5432 |
| Redis | 6381 | 6379 |
## Workflow
### Development Flow
```bash
# 1. Work on dev branch
git checkout dev
# ... make changes ...
# 2. Push to dev (triggers staging deployment)
git push origin dev
# → Staging deploys automatically on port 3002
# 3. Test staging
curl http://localhost:3002/api/health
# 4. Merge to main (also triggers staging)
git checkout main
git merge dev
git push origin main
# → Staging updates automatically
# 5. When ready, merge to production
git checkout production
git merge main
git push origin production
# → Production deploys with zero-downtime
```
## Manual Commands
### Staging
```bash
# Start staging
docker compose -f docker-compose.staging.yml up -d
# Stop staging
docker compose -f docker-compose.staging.yml down
# View staging logs
docker compose -f docker-compose.staging.yml logs -f
# Check staging health
curl http://localhost:3002/api/health
```
### Production
```bash
# Start production
docker compose -f docker-compose.production.yml up -d
# Stop production
docker compose -f docker-compose.production.yml down
# View production logs
docker compose -f docker-compose.production.yml logs -f
# Check production health
curl http://localhost:3000/api/health
```
## Environment Variables
### Staging
- `NODE_ENV=staging`
- `NEXT_PUBLIC_BASE_URL=https://staging.dk0.dev`
- `LOG_LEVEL=debug` (more verbose logging)
### Production
- `NODE_ENV=production`
- `NEXT_PUBLIC_BASE_URL=https://dk0.dev`
- `LOG_LEVEL=info`
## Database Separation
- **Staging DB**: `portfolio_staging_db` (separate volume)
- **Production DB**: `portfolio_db` (separate volume)
- **No conflicts**: Staging can be reset without affecting production
## Monitoring
### Check Both Environments
```bash
# Staging
curl http://localhost:3002/api/health
# Production
curl http://localhost:3000/api/health
```
### View Container Status
```bash
# All containers
docker ps
# Staging only
docker ps | grep staging
# Production only
docker ps | grep -v staging
```
## Troubleshooting
### Staging Not Deploying
1. Check GitHub Actions workflow
2. Verify branch is `dev` or `main`
3. Check Docker logs: `docker compose -f docker-compose.staging.yml logs`
### Production Deployment Issues
1. Check health endpoint before deployment
2. Verify old container is running
3. Check logs: `docker compose -f docker-compose.production.yml logs`
4. Manual rollback: Restart old container if needed
### Port Conflicts
- Staging uses 3002, 5434, 6381
- Production uses 3000, 5432, 6379
- If conflicts occur, check what's using the ports:
```bash
lsof -i :3002
lsof -i :3000
```
## Benefits
✅ **Safe testing**: Test on staging without risk
✅ **Zero-downtime**: Production updates don't interrupt service
✅ **Isolation**: Staging and production are completely separate
✅ **Automatic**: Deploys happen automatically on push
✅ **Rollback**: Automatic rollback if deployment fails
---
**You're all set!** Push to `dev`/`main` for staging, merge to `production` for production deployment! 🚀

28
TODO.md Normal file
View File

@@ -0,0 +1,28 @@
# Portfolio Roadmap
## Completed ✅
- [x] **Dark Mode Support**: `next-themes` integration, `ThemeToggle` component, and dark mode styles.
- [x] **Performance**: Replaced `<img>` with Next.js `<Image>` for optimization.
- [x] **SEO**: Added JSON-LD Structured Data for projects.
- [x] **Security**: Rate limiting added to `book-reviews`, `hobbies`, and `tech-stack` APIs.
- [x] **Book Reviews**:
- `ReadBooks` component updated to handle optional ratings/reviews.
- `CurrentlyReading` component verified.
- Automation guide created (`docs/N8N_HARDCOVER_GUIDE.md`).
- [x] **Testing**: Added tests for `book-reviews`, `hobbies`, `tech-stack`, `CurrentlyReading`, and `ThemeToggle`.
## Next Steps
### Directus CMS
- [ ] **Messages Collection**: Create `messages` collection in Directus for dynamic i18n (currently using `messages/*.json`).
- [ ] **Projects Migration**: Finish migrating projects content to Directus (script exists: `scripts/migrate-projects-to-directus.js`).
- [ ] **Webhooks**: Configure Directus webhooks for On-Demand ISR Revalidation.
### Features
- [ ] **Blog/Articles**: Design and implement the blog section.
- [ ] **Project Detail Gallery**: Add a lightbox/gallery for project screenshots.
### DevOps
- [ ] **GitHub Actions**: Migrate CI/CD fully to GitHub Actions (from Gitea).
- [ ] **Docker Optimization**: Further reduce image size.

View File

@@ -0,0 +1,39 @@
// Minimal Prisma Client mock for tests
// Export a PrismaClient class with the used methods stubbed out.
export class PrismaClient {
project = {
findMany: jest.fn(async () => []),
findUnique: jest.fn(async (_args: unknown) => null),
count: jest.fn(async () => 0),
create: jest.fn(async (data: unknown) => data),
update: jest.fn(async (data: unknown) => data),
delete: jest.fn(async (data: unknown) => data),
updateMany: jest.fn(async (_data: unknown) => ({})),
};
contact = {
create: jest.fn(async (data: unknown) => data),
findMany: jest.fn(async () => []),
count: jest.fn(async () => 0),
update: jest.fn(async (data: unknown) => data),
delete: jest.fn(async (data: unknown) => data),
};
pageView = {
create: jest.fn(async (data: unknown) => data),
count: jest.fn(async () => 0),
deleteMany: jest.fn(async () => ({})),
};
userInteraction = {
create: jest.fn(async (data: unknown) => data),
groupBy: jest.fn(async () => []),
deleteMany: jest.fn(async () => ({})),
};
$connect = jest.fn(async () => {});
$disconnect = jest.fn(async () => {});
}
export default PrismaClient;

100
app/[locale]/books/page.tsx Normal file
View File

@@ -0,0 +1,100 @@
"use client";
import { Star, ArrowLeft } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useLocale } from "next-intl";
import { Skeleton } from "@/app/components/ui/Skeleton";
import { BookReview } from "@/lib/directus";
export default function BooksPage() {
const locale = useLocale();
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchBooks = async () => {
try {
const res = await fetch(`/api/book-reviews?locale=${locale}`);
const data = await res.json();
if (data.bookReviews) setReviews(data.bookReviews);
} catch (error) {
console.error("Books fetch failed:", error);
} finally {
setLoading(false);
}
};
fetchBooks();
}, [locale]);
return (
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="mb-20">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{locale === 'de' ? 'Zurück' : 'Back Home'}</span>
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Library<span className="text-liquid-purple">.</span>
</h1>
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{locale === "de"
? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben."
: "Books that shaped my mindset and expanded my horizons."}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{loading ? (
Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
))
) : (
reviews?.map((review) => (
<div
key={review.id}
className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full hover:shadow-xl transition-all"
>
{review.book_image && (
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800">
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" />
</div>
)}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start gap-4 mb-4">
<h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3>
{review.rating && (
<div className="flex items-center gap-1 bg-stone-50 dark:bg-stone-800 px-3 py-1 rounded-full border border-stone-100 dark:border-stone-700">
<Star size={12} className="fill-amber-400 text-amber-400" />
<span className="text-xs font-black">{review.rating}</span>
</div>
)}
</div>
<p className="text-stone-500 dark:text-stone-400 font-bold text-sm mb-6">{review.book_author}</p>
{review.review && (
<div className="mt-auto pt-6 border-t border-stone-50 dark:border-stone-800">
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
&ldquo;{review.review.replace(/<[^>]*>/g, '')}&rdquo;
</p>
</div>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}

56
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { NextIntlClientProvider } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import React from "react";
import { notFound } from "next/navigation";
import ConsentBanner from "../components/ConsentBanner";
// Supported locales - must match middleware.ts
const SUPPORTED_LOCALES = ["en", "de"] as const;
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
function isValidLocale(locale: string): locale is SupportedLocale {
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
}
async function loadEnhancedMessages(locale: SupportedLocale) {
// Lade basis JSON Messages
const baseMessages = (await import(`../../messages/${locale}.json`)).default;
// Erweitere mit Directus (wenn verfügbar)
// Für jetzt: return base messages, Directus wird per Server Component geladen
return baseMessages;
}
// Define valid static params to prevent malicious path traversal
export function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Security: Validate locale to prevent malicious imports
if (!isValidLocale(locale)) {
notFound();
}
// Ensure next-intl actually uses the route segment locale for this request.
setRequestLocale(locale);
// Load messages explicitly by route locale to avoid falling back to the wrong
// language when request-level locale detection is unavailable/misconfigured.
const messages = await loadEnhancedMessages(locale);
return (
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<ConsentBanner />
</NextIntlClientProvider>
);
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../legal-notice/page";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
languages,
},
};
}

28
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,28 @@
import type { Metadata } from "next";
import HomePageServer from "../_ui/HomePageServer";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}`),
languages,
},
};
}
export default async function Page({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <HomePageServer locale={locale} />;
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export { default } from "../../privacy-policy/page";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
languages,
},
};
}

View File

@@ -0,0 +1,119 @@
import { prisma } from "@/lib/prisma";
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjectBySlug } from "@/lib/directus";
import { ProjectDetailData } from "@/app/_ui/ProjectDetailClient";
export const revalidate = 300;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
// Try Directus first for metadata
const directusProject = await getProjectBySlug(slug, locale);
if (directusProject) {
return {
title: directusProject.title,
description: directusProject.description,
alternates: {
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
languages: getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }),
},
};
}
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
languages,
},
};
}
export default async function ProjectPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
// Try PostgreSQL first
const dbProject = await prisma.project.findFirst({
where: { slug, published: true },
include: {
translations: {
select: { title: true, description: true, content: true, locale: true },
},
},
});
let projectData: ProjectDetailData | null = null;
if (dbProject) {
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
const trDefault = dbProject.translations?.find(
(t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description),
);
const tr = trPreferred ?? trDefault;
const { translations: _translations, ...rest } = dbProject;
const localizedContent = (() => {
if (typeof tr?.content === "string") return tr.content;
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
const markdown = (tr.content as Record<string, unknown>).markdown;
if (typeof markdown === "string") return markdown;
}
return dbProject.content;
})();
projectData = {
...rest,
title: tr?.title ?? dbProject.title,
description: tr?.description ?? dbProject.description,
content: localizedContent,
} as ProjectDetailData;
} else {
// Try Directus fallback
const directusProject = await getProjectBySlug(slug, locale);
if (directusProject) {
projectData = {
...directusProject,
id: typeof directusProject.id === 'string' ? (parseInt(directusProject.id) || 0) : directusProject.id,
} as ProjectDetailData;
}
}
if (!projectData) return notFound();
const jsonLd = {
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
"name": projectData.title,
"description": projectData.description,
"codeRepository": projectData.github_url || projectData.github,
"programmingLanguage": projectData.technologies,
"author": {
"@type": "Person",
"name": "Dennis Konkol"
},
"dateCreated": projectData.date || projectData.created_at,
"url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
"image": (projectData.imageUrl || projectData.image_url) ? toAbsoluteUrl((projectData.imageUrl || projectData.image_url)!) : undefined,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectDetailClient project={projectData} locale={locale} />
</>
);
}

View File

@@ -0,0 +1,94 @@
import { prisma } from "@/lib/prisma";
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjects as getDirectusProjects } from "@/lib/directus";
export const revalidate = 300;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const languages = getLanguageAlternates({ pathWithoutLocale: "projects" });
return {
alternates: {
canonical: toAbsoluteUrl(`/${locale}/projects`),
languages,
},
};
}
export default async function ProjectsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Fetch from PostgreSQL
const dbProjects = await prisma.project.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
include: {
translations: {
select: { title: true, description: true, locale: true },
},
},
});
// Fetch from Directus
let directusProjects: ProjectListItem[] = [];
try {
const fetched = await getDirectusProjects(locale, { published: true });
if (fetched) {
directusProjects = fetched.map(p => ({
...p,
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
})) as ProjectListItem[];
}
} catch (err) {
console.error("Directus projects fetch failed:", err);
}
const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
const trDefault = p.translations?.find(
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
);
const tr = trPreferred ?? trDefault;
return {
id: p.id,
slug: p.slug,
title: tr?.title ?? p.title,
description: tr?.description ?? p.description,
tags: p.tags,
category: p.category,
date: p.date,
createdAt: p.createdAt.toISOString(),
imageUrl: p.imageUrl,
};
});
// Merge projects, prioritizing DB ones if slugs match
const allProjects: ProjectListItem[] = [...localizedDb];
const dbSlugs = new Set(localizedDb.map(p => p.slug));
for (const dp of directusProjects) {
if (!dbSlugs.has(dp.slug)) {
allProjects.push(dp);
}
}
// Final sort by date
allProjects.sort((a, b) => {
const dateA = new Date(a.date || a.createdAt || 0).getTime();
const dateB = new Date(b.date || b.createdAt || 0).getTime();
return dateB - dateA;
});
return <ProjectsPageClient projects={allProjects} locale={locale} />;
}

View File

@@ -0,0 +1,109 @@
"use client";
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Snippet } from "@/lib/directus";
import { X, Copy, Check, Hash } from "lucide-react";
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
const [copied, setCopied] = useState(false);
const copyToClipboard = (code: string) => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
{initialSnippets.map((s, i) => (
<motion.button
key={s.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
onClick={() => setSelectedSnippet(s)}
className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group"
>
<div className="flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-xl bg-stone-50 dark:bg-stone-800 flex items-center justify-center text-stone-400 group-hover:text-liquid-purple transition-colors">
<Hash size={16} />
</div>
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">{s.category}</span>
</div>
<h3 className="text-2xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-4 group-hover:text-liquid-purple transition-colors">{s.title}</h3>
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed">
{s.description}
</p>
</motion.button>
))}
</div>
{/* Snippet Modal */}
<AnimatePresence>
{selectedSnippet && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedSnippet(null)}
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
>
<div className="p-8 md:p-10 overflow-y-auto">
<div className="flex justify-between items-start mb-8">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
</div>
<button
onClick={() => setSelectedSnippet(null)}
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
>
<X size={20} />
</button>
</div>
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
{selectedSnippet.description}
</p>
<div className="relative group/code">
<div className="absolute top-4 right-4 flex gap-2">
<button
onClick={() => copyToClipboard(selectedSnippet.code)}
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
title="Copy Code"
>
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
</button>
</div>
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
<code>{selectedSnippet.code}</code>
</pre>
</div>
</div>
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
<button
onClick={() => setSelectedSnippet(null)}
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
Close Laboratory
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
import { getSnippets } from "@/lib/directus";
import { Terminal, ArrowLeft } from "lucide-react";
import Link from "next/link";
import SnippetsClient from "./SnippetsClient";
export default async function SnippetsPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const snippets = await getSnippets(100) || [];
return (
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-all mb-12 group"
>
<ArrowLeft size={14} className="group-hover:-translate-x-1 transition-transform" />
Back to Portfolio
</Link>
<header className="mb-20">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900">
<Terminal size={24} />
</div>
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
The Lab<span className="text-liquid-purple">.</span>
</h1>
</div>
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-2xl leading-relaxed">
A collection of technical snippets, configurations, and mental notes from my daily building process.
</p>
</header>
<SnippetsClient initialSnippets={snippets} />
</div>
</main>
);
}

View File

@@ -0,0 +1,20 @@
import { NextResponse, NextRequest } from "next/server";
import { GET } from "@/app/api/book-reviews/route";
// Mock the route handler module
jest.mock("@/app/api/book-reviews/route", () => ({
GET: jest.fn(),
}));
describe("GET /api/book-reviews", () => {
it("should return book reviews", async () => {
(GET as jest.Mock).mockResolvedValue(
NextResponse.json({ bookReviews: [{ id: 1, book_title: "Test" }] })
);
const response = await GET({} as NextRequest);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.bookReviews).toHaveLength(1);
});
});

View File

@@ -13,7 +13,11 @@ beforeAll(() => {
});
afterAll(() => {
(console.error as jest.Mock).mockRestore();
// restoreMocks may already restore it; guard against calling mockRestore on non-mock
const maybeMock = console.error as unknown as jest.Mock | undefined;
if (maybeMock && typeof maybeMock.mockRestore === 'function') {
maybeMock.mockRestore();
}
});
beforeEach(() => {

View File

@@ -1,43 +1,28 @@
import { GET } from '@/app/api/fetchAllProjects/route';
import { NextResponse } from 'next/server';
// Wir mocken node-fetch direkt
jest.mock('node-fetch', () => {
return jest.fn(() =>
Promise.resolve({
json: () =>
Promise.resolve({
posts: [
{
id: '67ac8dfa709c60000117d312',
title: 'Just Doing Some Testing',
meta_description: 'Hello bla bla bla bla',
slug: 'just-doing-some-testing',
updated_at: '2025-02-13T14:25:38.000+00:00',
},
{
id: '67aaffc3709c60000117d2d9',
title: 'Blockchain Based Voting System',
meta_description:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
},
],
meta: {
pagination: {
limit: 'all',
next: null,
page: 1,
pages: 1,
prev: null,
total: 2,
},
},
}),
})
);
});
jest.mock('@/lib/prisma', () => ({
prisma: {
project: {
findMany: jest.fn(async () => [
{
id: 1,
slug: 'just-doing-some-testing',
title: 'Just Doing Some Testing',
updatedAt: new Date('2025-02-13T14:25:38.000Z'),
metaDescription: 'Hello bla bla bla bla',
},
{
id: 2,
slug: 'blockchain-based-voting-system',
title: 'Blockchain Based Voting System',
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
metaDescription:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
},
]),
},
},
}));
jest.mock('next/server', () => ({
NextResponse: {
@@ -46,12 +31,8 @@ jest.mock('next/server', () => ({
}));
describe('GET /api/fetchAllProjects', () => {
beforeAll(() => {
process.env.GHOST_API_URL = 'http://localhost:2368';
process.env.GHOST_API_KEY = 'some-key';
});
it('should return a list of projects (partial match)', async () => {
const { GET } = await import('@/app/api/fetchAllProjects/route');
await GET();
// Den tatsächlichen Argumentwert extrahieren
@@ -60,11 +41,11 @@ describe('GET /api/fetchAllProjects', () => {
expect(responseArg).toMatchObject({
posts: expect.arrayContaining([
expect.objectContaining({
id: '67ac8dfa709c60000117d312',
id: '1',
title: 'Just Doing Some Testing',
}),
expect.objectContaining({
id: '67aaffc3709c60000117d2d9',
id: '2',
title: 'Blockchain Based Voting System',
}),
]),

View File

@@ -1,32 +1,33 @@
import { GET } from '@/app/api/fetchProject/route';
import { NextRequest, NextResponse } from 'next/server';
import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch';
jest.mock('@/lib/prisma', () => ({
prisma: {
project: {
findUnique: jest.fn(async ({ where }: { where: { slug: string } }) => {
if (where.slug !== 'blockchain-based-voting-system') return null;
return {
id: 2,
title: 'Blockchain Based Voting System',
metaDescription:
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
description: null,
content: null,
};
}),
},
},
}));
jest.mock('next/server', () => ({
NextResponse: {
json: jest.fn(),
},
}));
describe('GET /api/fetchProject', () => {
beforeAll(() => {
process.env.GHOST_API_URL = 'http://localhost:2368';
process.env.GHOST_API_KEY = 'some-key';
global.fetch = mockFetch({
posts: [
{
id: '67aaffc3709c60000117d2d9',
title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
},
],
});
});
it('should fetch a project by slug', async () => {
const { GET } = await import('@/app/api/fetchProject/route');
const mockRequest = {
url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system',
} as unknown as NextRequest;
@@ -36,11 +37,11 @@ describe('GET /api/fetchProject', () => {
expect(NextResponse.json).toHaveBeenCalledWith({
posts: [
{
id: '67aaffc3709c60000117d2d9',
id: '2',
title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
updated_at: '2025-02-13T16:54:42.000Z',
},
],
});

View File

@@ -0,0 +1,20 @@
import { NextResponse, NextRequest } from "next/server";
import { GET } from "@/app/api/hobbies/route";
// Mock the route handler module
jest.mock("@/app/api/hobbies/route", () => ({
GET: jest.fn(),
}));
describe("GET /api/hobbies", () => {
it("should return hobbies", async () => {
(GET as jest.Mock).mockResolvedValue(
NextResponse.json({ hobbies: [{ id: 1, title: "Gaming" }] })
);
const response = await GET({} as NextRequest);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.hobbies).toHaveLength(1);
});
});

View File

@@ -1,44 +1,80 @@
import { GET } from '@/app/api/sitemap/route';
import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch';
jest.mock("next/server", () => {
const mockNextResponse = function (
body: string | object,
init?: { headers?: Record<string, string> },
) {
// Return an object that mimics NextResponse
const mockResponse = {
body,
init,
text: async () => {
if (typeof body === "string") {
return body;
} else if (body && typeof body === "object") {
return JSON.stringify(body);
}
return "";
},
json: async () => {
if (typeof body === "object") {
return body;
}
try {
return JSON.parse(body as string);
} catch {
return {};
}
},
};
return mockResponse;
};
jest.mock('next/server', () => ({
NextResponse: jest.fn().mockImplementation((body, init) => ({ body, init })),
return {
NextResponse: mockNextResponse,
};
});
jest.mock("@/lib/sitemap", () => ({
getSitemapEntries: jest.fn(async () => [
{
url: "https://dki.one/en",
lastModified: "2025-01-01T00:00:00.000Z",
},
{
url: "https://dki.one/de",
lastModified: "2025-01-01T00:00:00.000Z",
},
{
url: "https://dki.one/en/projects/blockchain-based-voting-system",
lastModified: "2025-02-13T16:54:42.000Z",
},
{
url: "https://dki.one/de/projects/blockchain-based-voting-system",
lastModified: "2025-02-13T16:54:42.000Z",
},
]),
generateSitemapXml: jest.fn(
() =>
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
),
}));
describe('GET /api/sitemap', () => {
describe("GET /api/sitemap", () => {
beforeAll(() => {
process.env.GHOST_API_URL = 'http://localhost:2368';
process.env.GHOST_API_KEY = 'test-api-key';
process.env.NEXT_PUBLIC_BASE_URL = 'https://dki.one';
global.fetch = mockFetch({
posts: [
{
id: '67ac8dfa709c60000117d312',
title: 'Just Doing Some Testing',
meta_description: 'Hello bla bla bla bla',
slug: 'just-doing-some-testing',
updated_at: '2025-02-13T14:25:38.000+00:00',
},
{
id: '67aaffc3709c60000117d2d9',
title: 'Blockchain Based Voting System',
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
slug: 'blockchain-based-voting-system',
updated_at: '2025-02-13T16:54:42.000+00:00',
},
],
});
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
});
it('should return a sitemap', async () => {
it("should return a sitemap", async () => {
const { GET } = await import("@/app/api/sitemap/route");
const response = await GET();
expect(response.body).toContain('<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">');
expect(response.body).toContain('<loc>https://dki.one/</loc>');
expect(response.body).toContain('<loc>https://dki.one/legal-notice</loc>');
expect(response.body).toContain('<loc>https://dki.one/privacy-policy</loc>');
expect(response.body).toContain('<loc>https://dki.one/projects/just-doing-some-testing</loc>');
expect(response.body).toContain('<loc>https://dki.one/projects/blockchain-based-voting-system</loc>');
// Get the body text from the NextResponse
const body = await response.text();
expect(body).toContain(
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
);
expect(body).toContain("<loc>https://dki.one/en</loc>");
// Note: Headers are not available in test environment
});
});
});

View File

@@ -0,0 +1,20 @@
import { NextResponse, NextRequest } from "next/server";
import { GET } from "@/app/api/tech-stack/route";
// Mock the route handler module
jest.mock("@/app/api/tech-stack/route", () => ({
GET: jest.fn(),
}));
describe("GET /api/tech-stack", () => {
it("should return tech stack", async () => {
(GET as jest.Mock).mockResolvedValue(
NextResponse.json({ techStack: [{ id: 1, name: "Frontend" }] })
);
const response = await GET({} as NextRequest);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.techStack).toHaveLength(1);
});
});

View File

@@ -0,0 +1,151 @@
import '@testing-library/jest-dom';
/**
* Unit tests for ActivityFeed NaN handling
*
* This test suite validates that the ActivityFeed component correctly handles
* NaN and numeric values in gaming and custom activity data to prevent
* "Received NaN for the children attribute" React errors.
*/
describe('ActivityFeed NaN Handling', () => {
describe('Gaming activity rendering logic', () => {
// Helper function to simulate getSafeGamingText behavior
const getSafeGamingText = (details: string | number | undefined, state: string | number | undefined, fallback: string): string => {
if (typeof details === 'string' && details.trim().length > 0) return details;
if (typeof state === 'string' && state.trim().length > 0) return state;
if (typeof details === 'number' && !isNaN(details)) return String(details);
if (typeof state === 'number' && !isNaN(state)) return String(state);
return fallback;
};
it('should safely handle NaN in gaming.details', () => {
const result = getSafeGamingText(NaN, 'Playing', 'Playing...');
expect(result).toBe('Playing'); // Should fall through NaN to state
expect(result).not.toBe(NaN);
expect(typeof result).toBe('string');
});
it('should safely handle NaN in both gaming.details and gaming.state', () => {
const result = getSafeGamingText(NaN, NaN, 'Playing...');
expect(result).toBe('Playing...'); // Should use fallback
expect(typeof result).toBe('string');
});
it('should prioritize string details over numeric state', () => {
const result = getSafeGamingText('Details text', 42, 'Playing...');
expect(result).toBe('Details text'); // String details takes precedence
expect(typeof result).toBe('string');
});
it('should prioritize string state over numeric details', () => {
const result = getSafeGamingText(42, 'State text', 'Playing...');
expect(result).toBe('State text'); // String state takes precedence over numeric details
expect(typeof result).toBe('string');
});
it('should convert valid numeric details to string', () => {
const result = getSafeGamingText(42, undefined, 'Playing...');
expect(result).toBe('42');
expect(typeof result).toBe('string');
});
it('should handle empty strings correctly', () => {
const result1 = getSafeGamingText('', 'Playing', 'Playing...');
expect(result1).toBe('Playing'); // Empty string should fall through to state
const result2 = getSafeGamingText(' ', 'Playing', 'Playing...');
expect(result2).toBe('Playing'); // Whitespace-only should fall through to state
});
it('should convert gaming.name to string safely', () => {
const validName = String('Test Game' || '');
expect(validName).toBe('Test Game');
expect(typeof validName).toBe('string');
// In the actual code, we use String(data.gaming.name || '')
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
const nanValue = NaN;
const nanName = String(nanValue || '');
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
expect(typeof nanName).toBe('string');
});
});
describe('Custom activities progress handling', () => {
it('should only render progress bar when progress is a valid number', () => {
const validProgress = 75;
const shouldRender = validProgress !== undefined &&
typeof validProgress === 'number' &&
!isNaN(validProgress);
expect(shouldRender).toBe(true);
});
it('should not render progress bar when progress is NaN', () => {
const invalidProgress = NaN;
const shouldRender = invalidProgress !== undefined &&
typeof invalidProgress === 'number' &&
!isNaN(invalidProgress);
expect(shouldRender).toBe(false);
});
it('should not render progress bar when progress is undefined', () => {
const undefinedProgress = undefined;
const shouldRender = undefinedProgress !== undefined &&
typeof undefinedProgress === 'number' &&
!isNaN(undefinedProgress);
expect(shouldRender).toBe(false);
});
});
describe('Custom activities dynamic field rendering', () => {
it('should safely convert valid numeric values to string', () => {
const value = 42;
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(true);
if (shouldRender) {
const stringValue = String(value);
expect(stringValue).toBe('42');
expect(typeof stringValue).toBe('string');
}
});
it('should not render NaN values', () => {
const value = NaN;
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(false);
});
it('should render valid string values', () => {
const value = 'Test String';
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(true);
if (shouldRender) {
const stringValue = String(value);
expect(stringValue).toBe('Test String');
expect(typeof stringValue).toBe('string');
}
});
it('should render zero as a valid numeric value', () => {
const value = 0;
const shouldRender = typeof value === 'string' ||
(typeof value === 'number' && !isNaN(value));
expect(shouldRender).toBe(true);
if (shouldRender) {
const stringValue = String(value);
expect(stringValue).toBe('0');
expect(typeof stringValue).toBe('string');
}
});
});
});

View File

@@ -0,0 +1,51 @@
import { render, screen, waitFor } from "@testing-library/react";
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
import React from "react";
// Mock next-intl completely to avoid ESM issues
jest.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));
// Mock next/image
jest.mock("next/image", () => ({
__esModule: true,
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
}));
describe("CurrentlyReading Component", () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it("renders skeleton when loading", () => {
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
const { container } = render(<CurrentlyReadingComp />);
expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
});
it("renders a book when data is fetched", async () => {
const mockBooks = [
{
title: "Test Book",
authors: ["Test Author"],
image: "/test.jpg",
progress: 50,
startedAt: "2024-01-01"
},
];
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ currentlyReading: mockBooks }),
});
render(<CurrentlyReadingComp />);
await waitFor(() => {
expect(screen.getByText("Test Book")).toBeInTheDocument();
expect(screen.getByText("Test Author")).toBeInTheDocument();
});
});
});

View File

@@ -1,27 +1,34 @@
import { render, screen } from '@testing-library/react';
import Header from '@/app/components/Header';
import '@testing-library/jest-dom';
// Mock next-intl
jest.mock('next-intl', () => ({
useLocale: () => 'en',
useTranslations: () => (key: string) => {
const messages: Record<string, string> = {
home: 'Home',
about: 'About',
projects: 'Projects',
contact: 'Contact'
};
return messages[key] || key;
},
}));
// Mock next/navigation
jest.mock('next/navigation', () => ({
usePathname: () => '/en',
}));
describe('Header', () => {
it('renders the header', () => {
it('renders the header with the dk logo', () => {
render(<Header />);
expect(screen.getByText('dk')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
const aboutButtons = screen.getAllByText('About');
expect(aboutButtons.length).toBeGreaterThan(0);
const projectsButtons = screen.getAllByText('Projects');
expect(projectsButtons.length).toBeGreaterThan(0);
const contactButtons = screen.getAllByText('Contact');
expect(contactButtons.length).toBeGreaterThan(0);
// Check for navigation links
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument();
expect(screen.getByText('Projects')).toBeInTheDocument();
expect(screen.getByText('Contact')).toBeInTheDocument();
});
it('renders the mobile header', () => {
render(<Header />);
// Check for mobile menu button (hamburger icon)
const menuButton = screen.getByRole('button');
expect(menuButton).toBeInTheDocument();
});
});
});

View File

@@ -1,12 +1,55 @@
import { render, screen } from '@testing-library/react';
import Hero from '@/app/components/Hero';
import '@testing-library/jest-dom';
// Mock next-intl
jest.mock('next-intl', () => ({
useLocale: () => 'en',
useTranslations: () => (key: string) => {
const messages: Record<string, string> = {
description: 'Dennis is a student and passionate self-hoster.',
ctaWork: 'View My Work'
};
return messages[key] || key;
},
}));
// Mock next/image
interface ImageProps {
src: string;
alt: string;
fill?: boolean;
priority?: boolean;
[key: string]: unknown;
}
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
<img
src={src}
alt={alt}
data-fill={fill?.toString()}
data-priority={priority?.toString()}
{...props}
/>
),
}));
describe('Hero', () => {
it('renders the hero section', () => {
it('renders the hero section correctly', () => {
render(<Hero />);
expect(screen.getByText('Dennis Konkol')).toBeInTheDocument();
expect(screen.getByText('Student & Software Engineer based in Osnabrück, Germany')).toBeInTheDocument();
expect(screen.getByAltText('Dennis Konkol - Software Engineer')).toBeInTheDocument();
// Check for the main headlines (defaults in Hero.tsx)
expect(screen.getByText('Building')).toBeInTheDocument();
expect(screen.getByText('Stuff.')).toBeInTheDocument();
// Check for the description from our mock
expect(screen.getByText(/Dennis is a student/i)).toBeInTheDocument();
// Check for the image
expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
// Check for CTA
expect(screen.getByText('View My Work')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,18 @@
import { render, screen } from "@testing-library/react";
import { ThemeToggle } from "@/app/components/ThemeToggle";
// Mock next-themes
jest.mock("next-themes", () => ({
useTheme: () => ({
theme: "light",
setTheme: jest.fn(),
}),
}));
describe("ThemeToggle Component", () => {
it("renders the theme toggle button", () => {
render(<ThemeToggle />);
// Initial render should have the button
expect(screen.getByRole("button")).toBeInTheDocument();
});
});

View File

@@ -1,10 +1,24 @@
import { render, screen } from '@testing-library/react';
import NotFound from '@/app/not-found';
import '@testing-library/jest-dom';
// Mock next/navigation
jest.mock('next/navigation', () => ({
useRouter: () => ({
back: jest.fn(),
push: jest.fn(),
}),
}));
// Mock next-intl
jest.mock('next-intl', () => ({
useLocale: () => 'en',
useTranslations: () => (key: string) => key,
}));
describe('NotFound', () => {
it('renders the 404 page', () => {
it('renders the 404 page with the new design text', () => {
render(<NotFound />);
expect(screen.getByText("Oops! The page you're looking for doesn't exist.")).toBeInTheDocument();
expect(screen.getByText(/Page not/i)).toBeInTheDocument();
expect(screen.getByText(/Found/i)).toBeInTheDocument();
});
});
});

View File

@@ -1,44 +1,41 @@
import '@testing-library/jest-dom';
import { GET } from '@/app/sitemap.xml/route';
import { mockFetch } from '@/app/__tests__/__mocks__/mock-fetch-sitemap';
import "@testing-library/jest-dom";
jest.mock('next/server', () => ({
NextResponse: jest.fn().mockImplementation((body, init) => ({ body, init })),
jest.mock("next/server", () => ({
NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => {
const response = {
body,
init,
};
return response;
}),
}));
describe('Sitemap Component', () => {
jest.mock("@/lib/sitemap", () => ({
getSitemapEntries: jest.fn(async () => [
{
url: "https://dki.one/en",
lastModified: "2025-01-01T00:00:00.000Z",
},
]),
generateSitemapXml: jest.fn(
() =>
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
),
}));
describe("Sitemap Component", () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_BASE_URL = 'https://dki.one';
global.fetch = mockFetch(`
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://dki.one/</loc>
</url>
<url>
<loc>https://dki.one/legal-notice</loc>
</url>
<url>
<loc>https://dki.one/privacy-policy</loc>
</url>
<url>
<loc>https://dki.one/projects/just-doing-some-testing</loc>
</url>
<url>
<loc>https://dki.one/projects/blockchain-based-voting-system</loc>
</url>
</urlset>
`);
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
});
it('should render the sitemap XML', async () => {
it("should render the sitemap XML", async () => {
const { GET } = await import("@/app/sitemap.xml/route");
const response = await GET();
expect(response.body).toContain('<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">');
expect(response.body).toContain('<loc>https://dki.one/</loc>');
expect(response.body).toContain('<loc>https://dki.one/legal-notice</loc>');
expect(response.body).toContain('<loc>https://dki.one/privacy-policy</loc>');
expect(response.body).toContain('<loc>https://dki.one/projects/just-doing-some-testing</loc>');
expect(response.body).toContain('<loc>https://dki.one/projects/blockchain-based-voting-system</loc>');
expect(response.body).toContain(
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
);
expect(response.body).toContain("<loc>https://dki.one/en</loc>");
// Note: Headers are not available in test environment
});
});
});

119
app/_ui/HomePage.tsx Normal file
View File

@@ -0,0 +1,119 @@
import Header from "../components/Header";
import Hero from "../components/Hero";
import About from "../components/About";
import Projects from "../components/Projects";
import Contact from "../components/Contact";
import Footer from "../components/Footer";
import Script from "next/script";
import { useEffect } from "react";
export default function HomePage() {
useEffect(() => {
// Force scroll to top on mount to prevent starting at lower sections
window.scrollTo(0, 0);
}, []);
return (
<div className="min-h-screen">
<Script
id={"structured-data"}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Person",
name: "Dennis Konkol",
url: "https://dk0.dev",
jobTitle: "Software Engineer",
address: {
"@type": "PostalAddress",
addressLocality: "Osnabrück",
addressCountry: "Germany",
},
sameAs: [
"https://github.com/Denshooter",
"https://linkedin.com/in/dkonkol",
],
}),
}}
/>
<Header />
{/* Spacer to prevent navbar overlap */}
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<Hero />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,64 C240,96 480,32 720,64 C960,96 1200,32 1440,64 L1440,120 L0,120 Z"
fill="url(#gradient1)"
/>
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#BAE6FD" stopOpacity="0.4" />
<stop offset="50%" stopColor="#DDD6FE" stopOpacity="0.4" />
<stop offset="100%" stopColor="#FBCFE8" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<About />
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,32 C240,64 480,96 720,32 C960,64 1200,96 1440,32 L1440,120 L0,120 Z"
fill="url(#gradient2)"
/>
<defs>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FED7AA" stopOpacity="0.4" />
<stop offset="50%" stopColor="#FDE68A" stopOpacity="0.4" />
<stop offset="100%" stopColor="#FCA5A5" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<Projects />
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,96 C240,32 480,64 720,96 C960,32 1200,64 1440,96 L1440,120 L0,120 Z"
fill="url(#gradient3)"
/>
<defs>
<linearGradient id="gradient3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#99F6E4" stopOpacity="0.4" />
<stop offset="50%" stopColor="#A7F3D0" stopOpacity="0.4" />
<stop offset="100%" stopColor="#D9F99D" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<Contact />
</main>
<Footer />
</div>
);
}

134
app/_ui/HomePageServer.tsx Normal file
View File

@@ -0,0 +1,134 @@
import Header from "../components/Header.server";
import Script from "next/script";
import {
getHeroTranslations,
getAboutTranslations,
getProjectsTranslations,
getContactTranslations,
getFooterTranslations,
} from "@/lib/translations-loader";
import {
HeroClient,
AboutClient,
ProjectsClient,
ContactClient,
FooterClient,
} from "../components/ClientWrappers";
interface HomePageServerProps {
locale: string;
}
export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations
const [heroT, aboutT, projectsT, contactT, footerT] = await Promise.all([
getHeroTranslations(locale),
getAboutTranslations(locale),
getProjectsTranslations(locale),
getContactTranslations(locale),
getFooterTranslations(locale),
]);
return (
<div className="min-h-screen">
<Script
id={"structured-data"}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Person",
name: "Dennis Konkol",
url: "https://dk0.dev",
jobTitle: "Software Engineer",
address: {
"@type": "PostalAddress",
addressLocality: "Osnabrück",
addressCountry: "Germany",
},
sameAs: [
"https://github.com/Denshooter",
"https://linkedin.com/in/dkonkol",
],
}),
}}
/>
<Header locale={locale} />
{/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<HeroClient locale={locale} translations={heroT} />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,64 C240,96 480,32 720,64 C960,96 1200,32 1440,64 L1440,120 L0,120 Z"
fill="url(#gradient1)"
/>
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#BAE6FD" stopOpacity="0.4" />
<stop offset="50%" stopColor="#DDD6FE" stopOpacity="0.4" />
<stop offset="100%" stopColor="#FBCFE8" stopOpacity="0.4" />
</linearGradient>
</defs>
</svg>
</div>
<AboutClient locale={locale} translations={aboutT} />
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,64 C360,96 720,32 1080,64 C1200,96 1320,32 1440,64 L1440,0 L0,0 Z"
fill="url(#gradient2)"
/>
<defs>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#A7F3D0" stopOpacity="0.3" />
<stop offset="50%" stopColor="#BFDBFE" stopOpacity="0.3" />
<stop offset="100%" stopColor="#DDD6FE" stopOpacity="0.3" />
</linearGradient>
</defs>
</svg>
</div>
<ProjectsClient locale={locale} translations={projectsT} />
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
preserveAspectRatio="none"
>
<path
d="M0,32 C240,64 480,0 720,32 C960,64 1200,0 1440,32 L1440,120 L0,120 Z"
fill="url(#gradient3)"
/>
<defs>
<linearGradient id="gradient3" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#FDE68A" stopOpacity="0.3" />
<stop offset="50%" stopColor="#FCA5A5" stopOpacity="0.3" />
<stop offset="100%" stopColor="#C4B5FD" stopOpacity="0.3" />
</linearGradient>
</defs>
</svg>
</div>
<ContactClient locale={locale} translations={contactT} />
</main>
<FooterClient locale={locale} translations={footerT} />
</div>
);
}

View File

@@ -0,0 +1,170 @@
"use client";
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from "next/navigation";
export type ProjectDetailData = {
id: number;
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date?: string;
created_at?: string;
github?: string | null;
github_url?: string | null;
live?: string | null;
button_live_label?: string | null;
button_github_label?: string | null;
imageUrl?: string | null;
image_url?: string | null;
technologies?: string[];
};
export default function ProjectDetailClient({
project,
locale,
}: {
project: ProjectDetailData;
locale: string;
}) {
const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail");
const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => {
// Prüfen, ob wir eine History haben (von Home gekommen)
if (typeof window !== 'undefined' && window.history.length > 1) {
setCanGoBack(true);
}
}, []);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
// Wenn wir direkt auf die Seite gekommen sind (Deep Link), gehen wir zur Projektliste
// Ansonsten nutzen wir den Browser-Back, um an die exakte Stelle der Home oder Liste zurückzukehren
if (canGoBack) {
router.back();
} else {
router.push(`/${locale}/projects`);
}
};
return (
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Navigation - Intelligent Back */}
<button
onClick={handleBack}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">
{tCommon("back")}
</span>
</button>
{/* Title Section */}
<div className="mb-20">
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
{project.title}<span className="text-liquid-mint">.</span>
</h1>
<p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
{project.description}
</p>
</div>
{/* Feature Image Box */}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-4 md:p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm mb-12 overflow-hidden">
<div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
{project.imageUrl ? (
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
) : (
<div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
<span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="lg:col-span-8 space-y-8">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
</div>
</div>
<div className="lg:col-span-4 space-y-8">
{/* Quick Links Box - Only show if links exist */}
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
<div className="space-y-4">
{project.live && project.live !== "#" && (
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black hover:scale-105 transition-transform group">
<span>{project.button_live_label || tDetail("liveDemo")}</span>
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform" />
</a>
)}
{project.github && project.github !== "#" && (
<a href={project.github} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-stone-800 text-white border border-stone-700 rounded-2xl font-black hover:bg-stone-700 transition-colors group">
<span>{project.button_github_label || tDetail("viewSource")}</span>
<GithubIcon size={20} className="group-hover:rotate-12 transition-transform" />
</a>
)}
</div>
</div>
)}
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-stone-400">Stack</h3>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700">
{tag}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "../components/ui/Skeleton";
export type ProjectListItem = {
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
slug: string;
title: string;
description: string;
tags: string[];
category: string;
date?: string;
createdAt?: string;
imageUrl?: string | null;
};
export default function ProjectsPageClient({
projects,
locale,
}: {
projects: ProjectListItem[];
locale: string;
}) {
const tCommon = useTranslations("common");
const tList = useTranslations("projects.list");
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
// Simulate initial load for smoother entrance or handle actual fetch if needed
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
}, []);
const categories = useMemo(() => {
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
return ["all", ...unique];
}, [projects]);
const filteredProjects = useMemo(() => {
let result = projects;
if (selectedCategory !== "all") {
result = result.filter((project) => project.category === selectedCategory);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
);
}
return result;
}, [projects, selectedCategory, searchQuery]);
return (
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-24">
<Link
href={`/${locale}`}
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
</Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Archive<span className="text-liquid-mint">.</span>
</h1>
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
{tList("intro")}
</p>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
selectedCategory === cat
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
}`}
>
{cat === 'all' ? tList('all') : cat}
</button>
))}
</div>
<div className="relative w-full md:w-80">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<input
type="text"
placeholder={tList("searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-all shadow-sm"
/>
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{loading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
<div className="space-y-3">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))
) : (
filteredProjects.map((project) => (
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
{project.imageUrl && (
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
</div>
)}
<div className="flex-1 flex flex-col">
<div className="flex justify-between items-start mb-4">
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all">
<ArrowUpRight size={20} />
</div>
</div>
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
<div className="mt-auto flex flex-wrap gap-2">
{project.tags.slice(0, 3).map(tag => (
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
))}
</div>
</div>
</div>
</Link>
</motion.div>
)))}
</div>
</div>
</div>
);
}

View File

@@ -1,92 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Rate limiting - more generous for admin dashboard
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
}
);
}
// Check admin authentication - for admin dashboard requests, we trust the session
// The middleware has already verified the admin session for /manage routes
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireAdminAuth(request);
if (authError) {
return authError;
}
}
// Check cache first
const cachedStats = await analyticsCache.getOverallStats();
if (cachedStats) {
return NextResponse.json(cachedStats);
}
// Get analytics data
const projectsResult = await projectService.getAllProjects();
const projects = projectsResult.projects || projectsResult;
const performanceStats = await projectService.getPerformanceStats();
// Calculate analytics metrics
const analytics = {
overview: {
totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length,
featuredProjects: projects.filter(p => p.featured).length,
totalViews: projects.reduce((sum, p) => sum + ((p.analytics as Record<string, unknown>)?.views as number || 0), 0),
totalLikes: projects.reduce((sum, p) => sum + ((p.analytics as Record<string, unknown>)?.likes as number || 0), 0),
totalShares: projects.reduce((sum, p) => sum + ((p.analytics as Record<string, unknown>)?.shares as number || 0), 0),
avgLighthouse: projects.length > 0
? Math.round(projects.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projects.length)
: 0
},
projects: projects.map(project => ({
id: project.id,
title: project.title,
category: project.category,
difficulty: project.difficulty,
views: (project.analytics as Record<string, unknown>)?.views as number || 0,
likes: (project.analytics as Record<string, unknown>)?.likes as number || 0,
shares: (project.analytics as Record<string, unknown>)?.shares as number || 0,
lighthouse: (project.performance as Record<string, unknown>)?.lighthouse as number || 0,
published: project.published,
featured: project.featured,
createdAt: project.createdAt,
updatedAt: project.updatedAt
})),
categories: performanceStats.byCategory,
difficulties: performanceStats.byDifficulty,
performance: {
avgLighthouse: performanceStats.avgLighthouse,
totalViews: performanceStats.totalViews,
totalLikes: performanceStats.totalLikes,
totalShares: performanceStats.totalShares
}
};
// Cache the results
await analyticsCache.setOverallStats(analytics);
return NextResponse.json(analytics);
} catch (error) {
console.error('Analytics dashboard error:', error);
return NextResponse.json(
{ error: 'Failed to fetch analytics data' },
{ status: 500 }
);
}
}

View File

@@ -1,77 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireAdminAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Check admin authentication - for admin dashboard requests, we trust the session
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireAdminAuth(request);
if (authError) {
return authError;
}
}
// Get performance data from database
const pageViews = await prisma.pageView.findMany({
orderBy: { timestamp: 'desc' },
take: 1000 // Last 1000 page views
});
const userInteractions = await prisma.userInteraction.findMany({
orderBy: { timestamp: 'desc' },
take: 1000 // Last 1000 interactions
});
// Calculate performance metrics
const performance = {
pageViews: {
total: pageViews.length,
last24h: pageViews.filter(pv => {
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return new Date(pv.timestamp) > dayAgo;
}).length,
last7d: pageViews.filter(pv => {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
return new Date(pv.timestamp) > weekAgo;
}).length,
last30d: pageViews.filter(pv => {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(pv.timestamp) > monthAgo;
}).length
},
interactions: {
total: userInteractions.length,
last24h: userInteractions.filter(ui => {
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return new Date(ui.timestamp) > dayAgo;
}).length,
last7d: userInteractions.filter(ui => {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
return new Date(ui.timestamp) > weekAgo;
}).length,
last30d: userInteractions.filter(ui => {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(ui.timestamp) > monthAgo;
}).length
},
topPages: pageViews.reduce((acc, pv) => {
acc[pv.page] = (acc[pv.page] || 0) + 1;
return acc;
}, {} as Record<string, number>),
topInteractions: userInteractions.reduce((acc, ui) => {
acc[ui.type] = (acc[ui.type] || 0) + 1;
return acc;
}, {} as Record<string, number>)
};
return NextResponse.json(performance);
} catch (error) {
console.error('Performance analytics error:', error);
return NextResponse.json(
{ error: 'Failed to fetch performance data' },
{ status: 500 }
);
}
}

View File

@@ -1,199 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 3, 300000)) { // 3 requests per 5 minutes - more restrictive for reset
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 3, 300000)
}
}
);
}
// Check admin authentication
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
const authError = requireAdminAuth(request);
if (authError) {
return authError;
}
}
const { type } = await request.json();
switch (type) {
case 'analytics':
// Reset all project analytics
await prisma.project.updateMany({
data: {
analytics: {
views: 0,
likes: 0,
shares: 0,
comments: 0,
bookmarks: 0,
clickThroughs: 0,
bounceRate: 0,
avgTimeOnPage: 0,
uniqueVisitors: 0,
returningVisitors: 0,
conversionRate: 0,
socialShares: {
twitter: 0,
linkedin: 0,
facebook: 0,
github: 0
},
deviceStats: {
mobile: 0,
desktop: 0,
tablet: 0
},
locationStats: {},
referrerStats: {},
lastUpdated: new Date().toISOString()
}
}
});
break;
case 'pageviews':
// Clear PageView table
await prisma.pageView.deleteMany({});
break;
case 'interactions':
// Clear UserInteraction table
await prisma.userInteraction.deleteMany({});
break;
case 'performance':
// Reset performance metrics
await prisma.project.updateMany({
data: {
performance: {
lighthouse: 0,
loadTime: 0,
firstContentfulPaint: 0,
largestContentfulPaint: 0,
cumulativeLayoutShift: 0,
totalBlockingTime: 0,
speedIndex: 0,
accessibility: 0,
bestPractices: 0,
seo: 0,
performanceScore: 0,
mobileScore: 0,
desktopScore: 0,
coreWebVitals: {
lcp: 0,
fid: 0,
cls: 0
},
lastUpdated: new Date().toISOString()
}
}
});
break;
case 'all':
// Reset everything
await Promise.all([
// Reset analytics
prisma.project.updateMany({
data: {
analytics: {
views: 0,
likes: 0,
shares: 0,
comments: 0,
bookmarks: 0,
clickThroughs: 0,
bounceRate: 0,
avgTimeOnPage: 0,
uniqueVisitors: 0,
returningVisitors: 0,
conversionRate: 0,
socialShares: {
twitter: 0,
linkedin: 0,
facebook: 0,
github: 0
},
deviceStats: {
mobile: 0,
desktop: 0,
tablet: 0
},
locationStats: {},
referrerStats: {},
lastUpdated: new Date().toISOString()
}
}
}),
// Reset performance
prisma.project.updateMany({
data: {
performance: {
lighthouse: 0,
loadTime: 0,
firstContentfulPaint: 0,
largestContentfulPaint: 0,
cumulativeLayoutShift: 0,
totalBlockingTime: 0,
speedIndex: 0,
accessibility: 0,
bestPractices: 0,
seo: 0,
performanceScore: 0,
mobileScore: 0,
desktopScore: 0,
coreWebVitals: {
lcp: 0,
fid: 0,
cls: 0
},
lastUpdated: new Date().toISOString()
}
}
}),
// Clear tracking tables
prisma.pageView.deleteMany({}),
prisma.userInteraction.deleteMany({})
]);
break;
default:
return NextResponse.json(
{ error: 'Invalid reset type. Use: analytics, pageviews, interactions, performance, or all' },
{ status: 400 }
);
}
// Clear cache
await analyticsCache.clearAll();
return NextResponse.json({
success: true,
message: `Successfully reset ${type} data`,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Analytics reset error:', error);
return NextResponse.json(
{ error: 'Failed to reset analytics data' },
{ status: 500 }
);
}
}

View File

@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Log performance metrics (you can extend this to store in database)
console.log('Performance Metric:', {
timestamp: new Date().toISOString(),
...body,
});
// You could store this in a database or send to external service
// For now, we'll just log it since Umami handles the main analytics
return NextResponse.json({ success: true });
} catch (error) {
console.error('Analytics API Error:', error);
return NextResponse.json(
{ error: 'Failed to process analytics data' },
{ status: 500 }
);
}
}
export async function GET() {
return NextResponse.json({
message: 'Analytics API is running',
timestamp: new Date().toISOString(),
});
}

View File

@@ -5,14 +5,14 @@ export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 login attempts per minute
if (!checkRateLimit(ip, 20, 60000)) { // 20 login attempts per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
...getRateLimitHeaders(ip, 20, 60000)
}
}
);
@@ -37,28 +37,31 @@ export async function POST(request: NextRequest) {
}
// Get admin credentials from environment
const adminAuth = process.env.ADMIN_BASIC_AUTH || 'admin:default_password_change_me';
const adminAuth = process.env.ADMIN_BASIC_AUTH;
if (!adminAuth || adminAuth.trim() === '' || adminAuth === 'admin:default_password_change_me') {
return new NextResponse(
JSON.stringify({ error: 'Admin auth is not configured' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
const [, expectedPassword] = adminAuth.split(':');
// Secure password comparison
if (password === expectedPassword) {
// Generate cryptographically secure session token
const timestamp = Date.now();
const crypto = await import('crypto');
const randomBytes = crypto.randomBytes(32);
const randomString = randomBytes.toString('hex');
// Create session data
const sessionData = {
timestamp,
random: randomString,
ip: ip,
userAgent: request.headers.get('user-agent') || 'unknown'
};
// Encrypt session data
const sessionJson = JSON.stringify(sessionData);
const sessionToken = btoa(sessionJson);
// Secure password comparison using constant-time comparison
const crypto = await import('crypto');
const passwordBuffer = Buffer.from(password, 'utf8');
const expectedBuffer = Buffer.from(expectedPassword, 'utf8');
// Use constant-time comparison to prevent timing attacks
if (passwordBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(passwordBuffer, expectedBuffer)) {
const { createSessionToken } = await import('@/lib/auth');
const sessionToken = createSessionToken(request);
if (!sessionToken) {
return new NextResponse(
JSON.stringify({ error: 'Session secret not configured' }),
{ status: 503, headers: { 'Content-Type': 'application/json' } }
);
}
return new NextResponse(
JSON.stringify({

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
export async function POST() {
try {
// Simple logout - just return success
// The client will handle clearing the session storage
return new NextResponse(
JSON.stringify({ success: true, message: 'Logged out successfully' }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
}
);
} catch {
return new NextResponse(
JSON.stringify({ error: 'Logout failed' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifySessionToken } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
@@ -20,70 +21,26 @@ export async function POST(request: NextRequest) {
);
}
// Decode and validate session token
try {
const decodedJson = atob(sessionToken);
const sessionData = JSON.parse(decodedJson);
// Validate session data structure
if (!sessionData.timestamp || !sessionData.random || !sessionData.ip || !sessionData.userAgent) {
return new NextResponse(
JSON.stringify({ valid: false, error: 'Invalid session token structure' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Check if session is still valid (2 hours)
const sessionTime = sessionData.timestamp;
const now = Date.now();
const sessionDuration = 2 * 60 * 60 * 1000; // 2 hours
if (now - sessionTime > sessionDuration) {
return new NextResponse(
JSON.stringify({ valid: false, error: 'Session expired' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Validate IP address (optional, but good security practice)
const currentIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (sessionData.ip !== currentIp) {
// Log potential session hijacking attempt
console.warn(`Session IP mismatch: expected ${sessionData.ip}, got ${currentIp}`);
return new NextResponse(
JSON.stringify({ valid: false, error: 'Session validation failed' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Validate User-Agent (optional)
const currentUserAgent = request.headers.get('user-agent') || 'unknown';
if (sessionData.userAgent !== currentUserAgent) {
console.warn(`Session User-Agent mismatch`);
return new NextResponse(
JSON.stringify({ valid: false, error: 'Session validation failed' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const valid = verifySessionToken(request, sessionToken);
if (!valid) {
return new NextResponse(
JSON.stringify({ valid: true, message: 'Session valid' }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block'
}
}
);
} catch {
return new NextResponse(
JSON.stringify({ valid: false, error: 'Invalid session token format' }),
JSON.stringify({ valid: false, error: 'Session expired or invalid' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
return new NextResponse(
JSON.stringify({ valid: true, message: 'Session valid' }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block'
}
}
);
} catch {
return new NextResponse(
JSON.stringify({ valid: false, error: 'Internal server error' }),

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server';
import { getBookReviews } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/**
* GET /api/book-reviews
*
* Loads Book Reviews from Directus CMS
*
* Query params:
* - locale: en or de (default: en)
*/
export async function GET(request: NextRequest) {
// Rate Limit: 60 requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 60, 60000)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en';
const reviews = await getBookReviews(locale);
if (process.env.NODE_ENV === 'development') {
console.log(`[API] Book Reviews geladen für ${locale}:`, reviews?.length || 0);
}
if (reviews && reviews.length > 0) {
return NextResponse.json(
{ bookReviews: reviews, source: 'directus' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
return NextResponse.json(
{ bookReviews: null, source: 'fallback' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error loading book reviews:', error);
}
return NextResponse.json(
{ bookReviews: null, error: 'Failed to load book reviews', source: 'error' },
{ status: 500 }
);
}
}

View File

@@ -1,13 +1,33 @@
import { type NextRequest, NextResponse } from "next/server";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { checkRateLimit, getRateLimitHeaders, requireSessionAuth } from '@/lib/auth';
import { prisma } from "@/lib/prisma";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Rate limiting for PUT requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 requests per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
}
);
}
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const resolvedParams = await params;
const id = parseInt(resolvedParams.id);
const body = await request.json();
@@ -35,7 +55,20 @@ export async function PUT(
});
} catch (error) {
console.error('Error updating contact:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Contact table does not exist.');
}
return NextResponse.json(
{ error: 'Database table not found. Please run migrations.' },
{ status: 503 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error updating contact:', error);
}
return NextResponse.json(
{ error: 'Failed to update contact' },
{ status: 500 }
@@ -48,6 +81,26 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Rate limiting for DELETE requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 3, 60000)) { // 3 requests per minute for DELETE (more restrictive)
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 3, 60000)
}
}
);
}
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const resolvedParams = await params;
const id = parseInt(resolvedParams.id);
@@ -67,7 +120,20 @@ export async function DELETE(
});
} catch (error) {
console.error('Error deleting contact:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Contact table does not exist.');
}
return NextResponse.json(
{ error: 'Database table not found. Please run migrations.' },
{ status: 503 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error deleting contact:', error);
}
return NextResponse.json(
{ error: 'Failed to delete contact' },
{ status: 500 }

View File

@@ -1,10 +1,15 @@
import { type NextRequest, NextResponse } from "next/server";
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { checkRateLimit, getRateLimitHeaders, requireSessionAuth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
try {
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const { searchParams } = new URL(request.url);
const filter = searchParams.get('filter') || 'all';
const limit = parseInt(searchParams.get('limit') || '50');
@@ -40,7 +45,21 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
console.error('Error fetching contacts:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Contact table does not exist. Returning empty result.');
}
return NextResponse.json({
contacts: [],
total: 0,
hasMore: false
});
}
if (process.env.NODE_ENV === 'development') {
console.error('Error fetching contacts:', error);
}
return NextResponse.json(
{ error: 'Failed to fetch contacts' },
{ status: 500 }
@@ -50,6 +69,21 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
// Rate limiting for POST requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 requests per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
}
);
}
const body = await request.json();
const { name, email, subject, message } = body;
@@ -86,7 +120,20 @@ export async function POST(request: NextRequest) {
}, { status: 201 });
} catch (error) {
console.error('Error creating contact:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Contact table does not exist.');
}
return NextResponse.json(
{ error: 'Database table not found. Please run migrations.' },
{ status: 503 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error creating contact:', error);
}
return NextResponse.json(
{ error: 'Failed to create contact' },
{ status: 500 }

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { getContentByKey } from "@/lib/content";
import { getContentPage } from "@/lib/directus";
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const key = searchParams.get("key");
const locale = searchParams.get("locale") || "en";
if (!key) {
return NextResponse.json({ error: "key is required" }, { status: 400 });
}
try {
// 1) Try Directus first
const directusPage = await getContentPage(key, locale);
if (directusPage) {
return NextResponse.json(
{
content: {
title: directusPage.title,
slug: directusPage.slug,
locale: directusPage.locale || locale,
content: directusPage.content,
},
source: "directus",
},
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
// 2) Fallback: PostgreSQL
const translation = await getContentByKey({ key, locale });
if (!translation) {
return NextResponse.json(
{ content: null },
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
return NextResponse.json(
{ content: translation, source: "postgresql" },
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
// If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings.
if (process.env.NODE_ENV === "development") {
console.warn("Content API failed; returning null content:", error);
}
return NextResponse.json({ content: null });
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSessionAuth } from "@/lib/auth";
import { upsertContentByKey } from "@/lib/content";
export async function GET(request: NextRequest) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const pages = await prisma.contentPage.findMany({
orderBy: { key: "asc" },
include: {
translations: {
select: { locale: true, updatedAt: true, title: true, slug: true },
},
},
});
return NextResponse.json({ pages });
}
export async function POST(request: NextRequest) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const body = await request.json();
const { key, locale, title, slug, content, metaDescription, keywords } = body as Record<string, unknown>;
if (!key || typeof key !== "string") {
return NextResponse.json({ error: "key is required" }, { status: 400 });
}
if (!locale || typeof locale !== "string") {
return NextResponse.json({ error: "locale is required" }, { status: 400 });
}
if (!content || typeof content !== "object") {
return NextResponse.json({ error: "content (JSON) is required" }, { status: 400 });
}
const saved = await upsertContentByKey({
key,
locale,
title: typeof title === "string" ? title : null,
slug: typeof slug === "string" ? slug : null,
content,
metaDescription: typeof metaDescription === "string" ? metaDescription : null,
keywords: typeof keywords === "string" ? keywords : null,
});
return NextResponse.json({ saved });
}

View File

@@ -2,436 +2,248 @@ import { type NextRequest, NextResponse } from "next/server";
import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders, getClientIp, requireSessionAuth } from "@/lib/auth";
const BRAND = {
siteUrl: "https://dk0.dev",
email: "contact@dk0.dev",
bg: "#FDFCF8",
sand: "#F3F1E7",
border: "#E7E5E4",
text: "#292524",
muted: "#78716C",
mint: "#A7F3D0",
red: "#EF4444",
};
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function nl2br(input: string): string {
return input.replace(/\r\n|\r|\n/g, "<br>");
}
function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) {
const sentAt = new Date().toLocaleString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(opts.title)}</title>
</head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:${BRAND.text};">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid ${BRAND.border};border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<div style="background:${BRAND.text};padding:22px 26px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:800;font-size:16px;color:${BRAND.bg};">Dennis Konkol</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:800;font-size:14px;color:${BRAND.bg};">
dk<span style="color:${BRAND.red};">0</span>.dev
</div>
</div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:900;letter-spacing:-0.02em;color:${BRAND.bg};">${escapeHtml(opts.title)}</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">${escapeHtml(opts.subtitle)}${sentAt}</div>
</div>
<div style="height:3px;background:${BRAND.mint};margin-top:18px;border-radius:999px;"></div>
</div>
<div style="padding:26px;">
${opts.bodyHtml}
</div>
<div style="padding:18px 26px;background:${BRAND.bg};border-top:1px solid ${BRAND.border};">
<div style="font-size:12px;color:${BRAND.muted};line-height:1.5;">
Automatisch generiert von <a href="${BRAND.siteUrl}" style="color:${BRAND.text};text-decoration:underline;">dk0.dev</a> •
<a href="mailto:${BRAND.email}" style="color:${BRAND.text};text-decoration:underline;">${BRAND.email}</a>
</div>
</div>
</div>
</div>
</body>
</html>
`.trim();
}
// Email templates with beautiful designs
const emailTemplates = {
welcome: {
subject: "Vielen Dank für deine Nachricht! 👋",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Willkommen - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
👋 Hallo ${name}!
</h1>
<p style="color: #d1fae5; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Vielen Dank für deine Nachricht
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Welcome Message -->
<div style="background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #bbf7d0;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">✓</span>
</div>
<h2 style="color: #065f46; margin: 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
</div>
<p style="color: #047857; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Nachricht! Ich habe sie erhalten und werde mich so schnell wie möglich bei dir melden.
</p>
</div>
<!-- Original Message Reference -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
Deine ursprüngliche Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Next Steps -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
🚀 Was passiert als nächstes?
</h3>
<div style="display: grid; gap: 15px;">
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">📧</span>
<div>
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Schnelle Antwort</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich antworte normalerweise innerhalb von 24 Stunden</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">💼</span>
<div>
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Projekt-Diskussion</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Gerne besprechen wir dein Projekt im Detail</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🤝</span>
<div>
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">Zusammenarbeit</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Lass uns gemeinsam etwas Großartiges schaffen!</p>
</div>
</div>
</div>
</div>
<!-- Portfolio Links -->
<div style="text-align: center; margin-top: 30px;">
<h3 style="color: #374151; margin: 0 0 20px 0; font-size: 18px; font-weight: 600;">Entdecke mehr von mir</h3>
<div style="display: flex; justify-content: center; gap: 15px; flex-wrap: wrap;">
<a href="https://dk0.dev" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
🌐 Portfolio
</a>
<a href="https://github.com/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #374151 0%, #111827 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
💻 GitHub
</a>
<a href="https://linkedin.com/in/denniskonkol" style="display: inline-block; background: linear-gradient(135deg, #0077b5 0%, #005885 100%); color: #ffffff; text-decoration: none; padding: 12px 24px; border-radius: 8px; font-weight: 600; font-size: 14px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);">
💼 LinkedIn
</a>
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dk0.dev" style="color: #10b981; text-decoration: none; font-family: 'Monaco', 'Menlo', 'Consolas', monospace; font-weight: bold;">dk<span style="color: #ef4444;">0</span>.dev</a> •
<a href="mailto:contact@dk0.dev" style="color: #10b981; text-decoration: none;">contact@dk0.dev</a>
</p>
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</body>
</html>
`
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Danke, ${safeName}!`,
subtitle: "Nachricht erhalten",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
</div>
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
<div style="margin-top:20px;text-align:center;">
<a href="${BRAND.siteUrl}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Portfolio ansehen
</a>
</div>
`.trim(),
});
},
},
project: {
subject: "Projekt-Anfrage erhalten! 🚀",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projekt-Anfrage - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
🚀 Projekt-Anfrage erhalten!
</h1>
<p style="color: #e9d5ff; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Hallo ${name}, lass uns etwas Großartiges schaffen!
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Project Message -->
<div style="background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e9d5ff;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">💼</span>
</div>
<h2 style="color: #6b21a8; margin: 0; font-size: 22px; font-weight: 600;">Bereit für dein Projekt!</h2>
</div>
<p style="color: #7c2d12; margin: 0; text-align: center; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Projekt-Anfrage! Ich bin gespannt darauf, mehr über deine Ideen zu erfahren und wie wir sie gemeinsam umsetzen können.
</p>
</div>
<!-- Original Message -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 10px;"></span>
Deine Projekt-Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Process Steps -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 20px 0; font-size: 18px; font-weight: 600; text-align: center;">
🎯 Mein Arbeitsprozess
</h3>
<div style="display: grid; gap: 15px;">
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #3b82f6;">
<span style="color: #3b82f6; font-size: 20px; margin-right: 15px;">💬</span>
<div>
<h4 style="color: #1e40af; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">1. Erstgespräch</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Wir besprechen deine Anforderungen im Detail</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #8b5cf6;">
<span style="color: #8b5cf6; font-size: 20px; margin-right: 15px;">📋</span>
<div>
<h4 style="color: #7c3aed; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">2. Konzept & Planung</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Ich erstelle ein detailliertes Konzept für dein Projekt</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #10b981;">
<span style="color: #10b981; font-size: 20px; margin-right: 15px;">⚡</span>
<div>
<h4 style="color: #059669; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">3. Entwicklung</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Agile Entwicklung mit regelmäßigen Updates</p>
</div>
</div>
<div style="display: flex; align-items: center; padding: 15px; background: #ffffff; border-radius: 8px; border-left: 4px solid #f59e0b;">
<span style="color: #f59e0b; font-size: 20px; margin-right: 15px;">🎉</span>
<div>
<h4 style="color: #d97706; margin: 0 0 4px 0; font-size: 14px; font-weight: 600;">4. Launch & Support</h4>
<p style="color: #4b5563; margin: 0; font-size: 14px;">Deployment und kontinuierlicher Support</p>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div style="text-align: center; margin-top: 30px;">
<a href="mailto:contact@dk0.dev?subject=Projekt-Diskussion mit ${name}" style="display: inline-block; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
💬 Projekt besprechen
</a>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dki.one" style="color: #8b5cf6; text-decoration: none;">dki.one</a> •
<a href="mailto:contact@dk0.dev" style="color: #8b5cf6; text-decoration: none;">contact@dk0.dev</a>
</p>
</div>
</div>
</body>
</html>
`
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",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
</div>
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Projekt-Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
<div style="margin-top:20px;text-align:center;">
<a href="mailto:${BRAND.email}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Kontakt aufnehmen
</a>
</div>
`.trim(),
});
},
},
quick: {
subject: "Danke für deine Nachricht! ⚡",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quick Response - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
⚡ Schnelle Antwort!
</h1>
<p style="color: #fef3c7; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Hallo ${name}, danke für deine Nachricht!
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Quick Response -->
<div style="background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #fde68a;">
<div style="text-align: center;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">⚡</span>
</div>
<h2 style="color: #92400e; margin: 0 0 15px 0; font-size: 22px; font-weight: 600;">Nachricht erhalten!</h2>
<p style="color: #a16207; margin: 0; line-height: 1.6; font-size: 16px;">
Vielen Dank für deine Nachricht! Ich werde mich so schnell wie möglich bei dir melden.
</p>
</div>
</div>
<!-- Original Message -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 10px;"></span>
Deine Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #f59e0b;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Quick Info -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 25px; border-radius: 12px; border: 1px solid #bfdbfe;">
<h3 style="color: #1e40af; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; text-align: center;">
📞 Kontakt
</h3>
<p style="color: #1e40af; margin: 0; text-align: center; line-height: 1.6; font-size: 14px;">
<strong>E-Mail:</strong> <a href="mailto:contact@dk0.dev" style="color: #1e40af; text-decoration: none;">contact@dk0.dev</a><br>
<strong>Portfolio:</strong> <a href="https://dki.one" style="color: #1e40af; text-decoration: none;">dki.one</a>
</p>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); border-radius: 1px;"></span>
</div>
<p style="color: #6b7280; margin: 0; font-size: 14px; line-height: 1.5;">
<strong>Dennis Konkol</strong> • Software Engineer & Student<br>
<a href="https://dki.one" style="color: #f59e0b; text-decoration: none;">dki.one</a>
</p>
</div>
</div>
</body>
</html>
`
template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({
title: `Danke, ${safeName}!`,
subtitle: "Kurze Bestätigung",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
</div>
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
`.trim(),
});
},
},
reply: {
subject: "Antwort auf deine Nachricht 📧",
template: (name: string, originalMessage: string) => `
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Antwort - Dennis Konkol</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
📧 Hallo ${name}!
</h1>
<p style="color: #dbeafe; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Hier ist meine Antwort auf deine Nachricht
</p>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Reply Message -->
<div style="background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #93c5fd;">
<div style="text-align: center; margin-bottom: 20px;">
<div style="width: 60px; height: 60px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; margin-bottom: 15px;">
<span style="color: #ffffff; font-size: 24px;">💬</span>
</div>
<h2 style="color: #1e40af; margin: 0; font-size: 22px; font-weight: 600;">Meine Antwort</h2>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="color: #1e40af; margin: 0; line-height: 1.6; font-size: 16px; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Original Message Reference -->
<div style="background: #ffffff; padding: 25px; border-radius: 12px; border: 1px solid #e5e7eb; margin-bottom: 30px;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 16px; font-weight: 600; display: flex; align-items: center;">
<span style="width: 6px; height: 6px; background: #6b7280; border-radius: 50%; margin-right: 10px;"></span>
Deine ursprüngliche Nachricht
</h3>
<div style="background: #f9fafb; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<p style="color: #4b5563; margin: 0; line-height: 1.6; font-style: italic; white-space: pre-wrap;">${originalMessage}</p>
</div>
</div>
<!-- Contact Info -->
<div style="background: #f8fafc; padding: 25px; border-radius: 12px; text-align: center; border: 1px solid #e2e8f0;">
<h3 style="color: #374151; margin: 0 0 15px 0; font-size: 18px; font-weight: 600;">Weitere Fragen?</h3>
<p style="color: #6b7280; margin: 0 0 20px 0; line-height: 1.6;">
Falls du weitere Fragen hast oder mehr über meine Projekte erfahren möchtest, zögere nicht, mir zu schreiben!
</p>
<div style="display: flex; justify-content: center; gap: 20px; flex-wrap: wrap;">
<a href="https://dki.one" style="display: inline-flex; align-items: center; padding: 12px 24px; background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); color: #ffffff; text-decoration: none; border-radius: 8px; font-weight: 500; transition: all 0.2s;">
🌐 Portfolio besuchen
</a>
<a href="mailto:contact@dk0.dev" style="display: inline-flex; align-items: center; padding: 12px 24px; background: #ffffff; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: 500; border: 2px solid #3b82f6; transition: all 0.2s;">
📧 Direkt antworten
</a>
</div>
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="color: #6b7280; margin: 0 0 10px 0; font-size: 14px; font-weight: 500;">
<strong>Dennis Konkol</strong> • <a href="https://dki.one" style="color: #3b82f6; text-decoration: none;">dki.one</a>
</p>
<p style="color: #9ca3af; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
</div>
</div>
</body>
</html>
`
}
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",
bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
Hey ${safeName},<br><br>
hier ist meine Antwort:
</div>
<div style="margin-top:14px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Antwort</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeResponse}
</div>
</div>
<div style="margin-top:16px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine ursprüngliche Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.border};">
${safeOriginal}
</div>
</div>
`.trim(),
});
},
},
};
export async function POST(request: NextRequest) {
try {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const ip = getClientIp(request);
if (!checkRateLimit(ip, 10, 60000)) {
return NextResponse.json(
{ error: "Rate limit exceeded" },
{ status: 429, headers: { ...getRateLimitHeaders(ip, 10, 60000) } },
);
}
const body = (await request.json()) as {
to: string;
name: string;
template: 'welcome' | 'project' | 'quick' | 'reply';
originalMessage: string;
response?: string;
};
const { to, name, template, originalMessage } = body;
console.log('📧 Email response request:', { to, name, template, messageLength: originalMessage.length });
const { to, name, template, originalMessage, response } = body;
// Validate input
if (!to || !name || !template || !originalMessage) {
console.error('❌ Validation failed: Missing required fields');
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@]+$/;
@@ -445,7 +257,6 @@ export async function POST(request: NextRequest) {
// Check if template exists
if (!emailTemplates[template]) {
console.error('❌ Validation failed: Invalid template');
return NextResponse.json(
{ error: "Ungültiges Template" },
{ status: 400 },
@@ -487,9 +298,7 @@ export async function POST(request: NextRequest) {
// Verify transport configuration
try {
await transport.verify();
console.log('✅ SMTP connection verified successfully');
} catch (verifyError) {
console.error('❌ SMTP verification failed:', verifyError);
} catch (_verifyError) {
return NextResponse.json(
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
{ status: 500 },
@@ -497,19 +306,27 @@ export async function POST(request: NextRequest) {
}
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<typeof template, "reply">;
html = emailTemplates[nonReplyTemplate].template(name, originalMessage);
}
const mailOptions: Mail.Options = {
from: `"Dennis Konkol" <${user}>`,
to: to,
replyTo: "contact@dk0.dev",
subject: selectedTemplate.subject,
html: selectedTemplate.template(name, originalMessage),
html,
text: `
Hallo ${name}!
Vielen Dank für deine Nachricht:
${originalMessage}
Ich werde mich so schnell wie möglich bei dir melden.
${template === "reply" ? `\nAntwort:\n${response || ""}\n` : "\nIch werde mich so schnell wie möglich bei dir melden.\n"}
Beste Grüße,
Dennis Konkol
@@ -519,23 +336,18 @@ contact@dk0.dev
`,
};
console.log('📤 Sending templated email...');
const sendMailPromise = () =>
new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, function (err, info) {
if (!err) {
console.log('✅ Templated email sent successfully:', info.response);
resolve(info.response);
} else {
console.error("❌ Error sending templated email:", err);
reject(err.message);
}
});
});
const result = await sendMailPromise();
console.log('🎉 Templated email process completed successfully');
return NextResponse.json({
message: "Template-E-Mail erfolgreich gesendet",
@@ -544,7 +356,6 @@ contact@dk0.dev
});
} catch (err) {
console.error("❌ Unexpected error in templated email API:", err);
return NextResponse.json({
error: "Fehler beim Senden der Template-E-Mail",
details: err instanceof Error ? err.message : 'Unbekannter Fehler'

View File

@@ -2,21 +2,57 @@ import { type NextRequest, NextResponse } from "next/server";
import nodemailer from "nodemailer";
import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer";
import { PrismaClient } from '@prisma/client';
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from "@/lib/prisma";
const prisma = new PrismaClient();
// 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();
}
function escapeHtml(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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
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)
}
}
);
}
const body = (await request.json()) as {
email: string;
name: string;
subject: string;
message: string;
};
const { email, name, subject, message } = body;
// 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);
console.log('📧 Email request received:', { email, name, subject, messageLength: message.length });
// Email request received
// Validate input
if (!email || !name || !subject || !message) {
@@ -46,15 +82,17 @@ export async function POST(request: NextRequest) {
);
}
// Validate field lengths
if (name.length > 100 || subject.length > 200 || message.length > 5000) {
return NextResponse.json(
{ error: "Eingabe zu lang" },
{ status: 400 },
);
}
const user = process.env.MY_EMAIL ?? "";
const pass = process.env.MY_PASSWORD ?? "";
console.log('🔑 Environment check:', {
hasEmail: !!user,
hasPassword: !!pass,
emailHost: user.split('@')[1] || 'unknown'
});
if (!user || !pass) {
console.error("❌ Missing email/password environment variables");
return NextResponse.json(
@@ -77,19 +115,15 @@ export async function POST(request: NextRequest) {
connectionTimeout: 30000, // 30 seconds
greetingTimeout: 30000, // 30 seconds
socketTimeout: 60000, // 60 seconds
// Additional TLS options for better compatibility
tls: {
rejectUnauthorized: false, // Allow self-signed certificates
ciphers: 'SSLv3'
}
// TLS hardening (allow insecure/self-signed only when explicitly enabled)
tls:
process.env.SMTP_ALLOW_INSECURE_TLS === "true" ||
process.env.SMTP_ALLOW_SELF_SIGNED === "true"
? { rejectUnauthorized: false }
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
};
console.log('🚀 Creating transport with options:', {
host: transportOptions.host,
port: transportOptions.port,
secure: transportOptions.secure,
user: user.split('@')[0] + '@***' // Hide full email in logs
});
// Creating transport with configured options
const transport = nodemailer.createTransport(transportOptions);
@@ -101,15 +135,17 @@ export async function POST(request: NextRequest) {
while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
try {
verificationAttempts++;
console.log(`🔍 SMTP verification attempt ${verificationAttempts}/${maxVerificationAttempts}`);
await transport.verify();
console.log('✅ SMTP connection verified successfully');
verificationSuccess = true;
} catch (verifyError) {
console.error(`❌ SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
if (process.env.NODE_ENV === 'development') {
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
}
if (verificationAttempts >= maxVerificationAttempts) {
console.error('❌ All SMTP verification attempts failed');
if (process.env.NODE_ENV === 'development') {
console.error('All SMTP verification attempts failed');
}
return NextResponse.json(
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
{ status: 500 },
@@ -121,6 +157,22 @@ export async function POST(request: NextRequest) {
}
}
const brandUrl = "https://dk0.dev";
const sentAt = new Date().toLocaleString('de-DE', {
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, "<br>");
const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`,
to: "contact@dk0.dev", // Send to your contact email
@@ -134,86 +186,80 @@ export async function POST(request: NextRequest) {
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neue Kontaktanfrage - Portfolio</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 600; letter-spacing: -0.5px;">
📧 Neue Kontaktanfrage
</h1>
<p style="color: #e2e8f0; margin: 8px 0 0 0; font-size: 16px; opacity: 0.9;">
Von deinem Portfolio
</p>
<body style="margin:0;padding:0;background-color:#fdfcf8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#292524;">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid #e7e5e4;border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<!-- Top bar -->
<div style="background:#292524;padding:22px 26px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:700;font-size:16px;letter-spacing:-0.01em;color:#fdfcf8;">
Dennis Konkol
</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:700;font-size:14px;color:#fdfcf8;">
dk<span style="color:#ef4444;">0</span>.dev
</div>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<!-- Contact Info Card -->
<div style="background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); padding: 30px; border-radius: 12px; margin-bottom: 30px; border: 1px solid #e2e8f0;">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-right: 15px;">
<span style="color: #ffffff; font-size: 20px; font-weight: bold;">${name.charAt(0).toUpperCase()}</span>
</div>
<div>
<h2 style="color: #1e293b; margin: 0; font-size: 24px; font-weight: 600;">${name}</h2>
<p style="color: #64748b; margin: 4px 0 0 0; font-size: 14px;">Kontaktanfrage</p>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px;">
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<h4 style="color: #059669; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">E-Mail</h4>
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${email}</p>
</div>
<div style="background: #ffffff; padding: 20px; border-radius: 8px; border-left: 4px solid #3b82f6;">
<h4 style="color: #2563eb; margin: 0 0 8px 0; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;">Betreff</h4>
<p style="color: #374151; margin: 0; font-size: 16px; font-weight: 500;">${subject}</p>
</div>
</div>
</div>
<!-- Message Card -->
<div style="background: #ffffff; padding: 30px; border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);">
<div style="display: flex; align-items: center; margin-bottom: 20px;">
<div style="width: 8px; height: 8px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 50%; margin-right: 12px;"></div>
<h3 style="color: #1e293b; margin: 0; font-size: 18px; font-weight: 600;">Nachricht</h3>
</div>
<div style="background: #f8fafc; padding: 25px; border-radius: 8px; border-left: 4px solid #667eea;">
<p style="color: #374151; margin: 0; line-height: 1.7; font-size: 16px; white-space: pre-wrap;">${message}</p>
</div>
</div>
<!-- Action Button -->
<div style="text-align: center; margin-top: 30px;">
<a href="mailto:${email}?subject=Re: ${subject}" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; padding: 15px 30px; border-radius: 8px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); transition: all 0.2s;">
📬 Antworten
</a>
</div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:800;letter-spacing:-0.02em;color:#fdfcf8;">
Neue Kontaktanfrage
</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">
Eingegangen am ${sentAt}
</div>
</div>
<!-- Footer -->
<div style="background: #f8fafc; padding: 30px; text-align: center; border-top: 1px solid #e2e8f0;">
<div style="margin-bottom: 15px;">
<span style="display: inline-block; width: 40px; height: 2px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 1px;"></span>
<div style="height:3px;background:#a7f3d0;margin-top:18px;border-radius:999px;"></div>
</div>
<!-- Content -->
<div style="padding:26px;">
<!-- Sender -->
<div style="display:flex;align-items:flex-start;gap:14px;">
<div style="width:44px;height:44px;border-radius:14px;background:#f3f1e7;border:1px solid #e7e5e4;display:flex;align-items:center;justify-content:center;font-weight:800;color:#292524;">
${escapeHtml(initial)}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#292524;line-height:1.2;">
${safeName}
</div>
<p style="color: #64748b; margin: 0; font-size: 14px; line-height: 1.5;">
Diese E-Mail wurde automatisch von deinem Portfolio generiert.<br>
<strong>Dennis Konkol Portfolio</strong> • <a href="https://dki.one" style="color: #667eea; text-decoration: none;">dki.one</a>
</p>
<p style="color: #94a3b8; margin: 10px 0 0 0; font-size: 12px;">
${new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
<div style="margin-top:6px;font-size:13px;color:#78716c;line-height:1.4;">
<span style="font-weight:700;color:#44403c;">E-Mail:</span> ${safeEmail}<br>
<span style="font-weight:700;color:#44403c;">Betreff:</span> ${safeSubject}
</div>
</div>
</div>
<!-- Message -->
<div style="margin-top:18px;background:#fdfcf8;border:1px solid #e7e5e4;border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:#f3f1e7;border-bottom:1px solid #e7e5e4;">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">
Nachricht
</div>
</div>
<div style="padding:16px;line-height:1.65;color:#292524;font-size:15px;border-left:4px solid #a7f3d0;">
${safeMessageHtml}
</div>
</div>
<!-- CTA -->
<div style="margin-top:22px;text-align:center;">
<a href="${escapeHtml(replyHref)}"
style="display:inline-block;background:#292524;color:#fdfcf8;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Antworten
</a>
<div style="margin-top:10px;font-size:12px;color:#78716c;">
Oder antworte direkt auf diese E-Mail.
</div>
</div>
</div>
<!-- Footer -->
<div style="padding:18px 26px;background:#fdfcf8;border-top:1px solid #e7e5e4;">
<div style="font-size:12px;color:#78716c;line-height:1.5;">
Automatisch generiert von <a href="${brandUrl}" style="color:#292524;text-decoration:underline;">dk0.dev</a>
</div>
</div>
</div>
</div>
</body>
</html>
`,
@@ -227,11 +273,11 @@ Nachricht:
${message}
---
Diese E-Mail wurde automatisch von deinem Portfolio generiert.
Diese E-Mail wurde automatisch von dk0.dev generiert.
`,
};
console.log('📤 Sending email...');
// Sending email
// Email sending with retry logic
let sendAttempts = 0;
@@ -242,16 +288,18 @@ Diese E-Mail wurde automatisch von deinem Portfolio generiert.
while (sendAttempts < maxSendAttempts && !sendSuccess) {
try {
sendAttempts++;
console.log(`📤 Email send attempt ${sendAttempts}/${maxSendAttempts}`);
// Email send attempt
const sendMailPromise = () =>
new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, function (err, info) {
if (!err) {
console.log('✅ Email sent successfully:', info.response);
// Email sent successfully
resolve(info.response);
} else {
console.error("❌ Error sending email:", err);
if (process.env.NODE_ENV === 'development') {
console.error("Error sending email:", err);
}
reject(err.message);
}
});
@@ -259,12 +307,16 @@ Diese E-Mail wurde automatisch von deinem Portfolio generiert.
result = await sendMailPromise();
sendSuccess = true;
console.log('🎉 Email process completed successfully');
// Email process completed successfully
} catch (sendError) {
console.error(`❌ Email send attempt ${sendAttempts} failed:`, sendError);
if (process.env.NODE_ENV === 'development') {
console.error(`Email send attempt ${sendAttempts} failed:`, sendError);
}
if (sendAttempts >= maxSendAttempts) {
console.error('❌ All email send attempts failed');
if (process.env.NODE_ENV === 'development') {
console.error('All email send attempts failed');
}
throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
}
@@ -284,9 +336,11 @@ Diese E-Mail wurde automatisch von deinem Portfolio generiert.
responded: false
}
});
console.log('✅ Contact saved to database');
// Contact saved to database
} catch (dbError) {
console.error('❌ Error saving contact to database:', 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
}

View File

@@ -1,57 +1,58 @@
import { NextResponse } from "next/server";
import http from "http";
import fetch from "node-fetch";
import NodeCache from "node-cache";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
type GhostPost = {
type LegacyPost = {
slug: string;
id: string;
title: string;
feature_image: string;
visibility: string;
published_at: string;
meta_description: string | null;
updated_at: string;
html: string;
reading_time: number;
meta_description: string;
};
type GhostPostsResponse = {
posts: Array<GhostPost>;
type LegacyPostsResponse = {
posts: Array<LegacyPost>;
};
export async function GET() {
const cacheKey = "ghostPosts";
const cachedPosts = cache.get<GhostPostsResponse>(cacheKey);
const cacheKey = "projects:legacyPosts";
const cachedPosts = cache.get<LegacyPostsResponse>(cacheKey);
if (cachedPosts) {
return NextResponse.json(cachedPosts);
}
try {
const agent = new http.Agent({ keepAlive: true });
const response = await fetch(
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`,
{ agent: agent as unknown as undefined }
);
const posts: GhostPostsResponse = await response.json() as GhostPostsResponse;
const projects = await prisma.project.findMany({
where: { published: true },
orderBy: { updatedAt: "desc" },
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
metaDescription: true,
},
});
if (!posts || !posts.posts) {
console.error("Invalid posts data");
return NextResponse.json([]);
}
const payload: LegacyPostsResponse = {
posts: projects.map((p) => ({
id: String(p.id),
slug: p.slug,
title: p.title,
meta_description: p.metaDescription ?? null,
updated_at: (p.updatedAt ?? new Date()).toISOString(),
})),
};
cache.set(cacheKey, posts); // Daten im Cache speichern
return NextResponse.json(posts);
cache.set(cacheKey, payload);
return NextResponse.json(payload);
} catch (error) {
console.error("Failed to fetch posts from Ghost:", error);
console.error("Failed to fetch projects:", error);
return NextResponse.json(
{ error: "Failed to fetch projects" },
{ status: 500 },

View File

@@ -12,9 +12,40 @@ export async function GET(req: NextRequest) {
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
// Try global fetch first, fall back to node-fetch if necessary
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
if (
typeof (globalThis as unknown as { fetch: unknown }).fetch ===
"function"
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response = await (globalThis as unknown as { fetch: any }).fetch(url);
}
} catch (_e) {
response = undefined;
}
if (!response || typeof response.ok === "undefined" || !response.ok) {
try {
const mod = await import("node-fetch");
const nodeFetch = (mod as { default: unknown }).default ?? mod;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
response = await (nodeFetch as any)(url);
} catch (err) {
console.error("Failed to fetch image:", err);
return NextResponse.json(
{ error: "Failed to fetch image" },
{ status: 500 },
);
}
}
if (!response || !response.ok) {
throw new Error(
`Failed to fetch image: ${response?.statusText ?? "no response"}`,
);
}
const contentType = response.headers.get("content-type");

View File

@@ -1,10 +1,8 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
@@ -14,16 +12,37 @@ export async function GET(request: Request) {
}
try {
const response = await fetch(
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
);
if (!response.ok) {
throw new Error(`Failed to fetch post: ${response.statusText}`);
const project = await prisma.project.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
metaDescription: true,
description: true,
content: true,
},
});
if (!project) {
return NextResponse.json({ posts: [] }, { status: 200 });
}
const post = await response.json();
return NextResponse.json(post);
// Legacy shape (Ghost-like) for compatibility with older frontend/tests.
return NextResponse.json({
posts: [
{
id: String(project.id),
title: project.title,
meta_description: project.metaDescription ?? project.description ?? "",
slug: project.slug,
updated_at: (project.updatedAt ?? new Date()).toISOString(),
},
],
});
} catch (error) {
console.error("Failed to fetch post from Ghost:", error);
console.error("Failed to fetch project:", error);
return NextResponse.json(
{ error: "Failed to fetch project" },
{ status: 500 },

54
app/api/hobbies/route.ts Normal file
View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { getHobbies } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/**
* GET /api/hobbies
*
* Loads Hobbies from Directus with fallback to static data
*
* Query params:
* - locale: en or de (default: en)
*/
export async function GET(request: NextRequest) {
// Rate Limit: 60 requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 60, 60000)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en';
// Try to load from Directus
const hobbies = await getHobbies(locale);
if (hobbies && hobbies.length > 0) {
return NextResponse.json(
{ hobbies, source: 'directus' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
// Fallback: return empty (component will use hardcoded fallback)
return NextResponse.json(
{ hobbies: null, source: 'fallback' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error loading hobbies:', error);
}
return NextResponse.json(
{ hobbies: null, error: 'Failed to load hobbies', source: 'error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLocalizedMessage } from '@/lib/i18n-loader';
import enMessages from '@/messages/en.json';
import deMessages from '@/messages/de.json';
// Cache für 5 Minuten
export const revalidate = 300;
const messagesMap = { en: enMessages, de: deMessages };
/**
* GET /api/i18n/[namespace]?locale=en
* Lädt alle Keys eines Namespace aus Directus oder JSON
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ namespace: string }> }
) {
const { namespace } = await params;
const locale = req.nextUrl.searchParams.get('locale') || 'en';
// Normalize locale (de-DE -> de)
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
try {
// Hole alle Keys aus JSON für diesen Namespace
const jsonData = messagesMap[normalizedLocale as 'en' | 'de'];
const namespaceData = getNestedValue(jsonData, namespace);
if (!namespaceData || typeof namespaceData !== 'object') {
return NextResponse.json({}, { status: 200 });
}
// Flatten das Objekt zu flachen Keys
const flatKeys = flattenObject(namespaceData as Record<string, unknown>);
// Lade jeden Key aus Directus (mit Fallback auf JSON)
const result: Record<string, string> = {};
await Promise.all(
Object.entries(flatKeys).map(async ([key, jsonValue]) => {
const fullKey = `${namespace}.${key}`;
const value = await getLocalizedMessage(fullKey, locale);
result[key] = value || String(jsonValue);
})
);
return NextResponse.json(result, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
});
} catch (error) {
console.error('i18n API error:', error);
return NextResponse.json({ error: 'Failed to load translations' }, { status: 500 });
}
}
// Helper: Holt verschachtelte Werte aus Objekt
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
if (current && typeof current === 'object' && key in current) {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}
// Helper: Flatten verschachteltes Objekt zu flachen Keys
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(result, flattenObject(value as Record<string, unknown>, newKey));
} else {
result[newKey] = String(value);
}
}
return result;
}

19
app/api/messages/route.ts Normal file
View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { getMessages } from "@/lib/directus";
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const locale = searchParams.get("locale") || "en";
try {
const messages = await getMessages(locale);
return NextResponse.json(
{ messages },
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch {
return NextResponse.json({ messages: {} }, { status: 500 });
}
}

308
app/api/n8n/chat/route.ts Normal file
View File

@@ -0,0 +1,308 @@
import { NextRequest, NextResponse } from "next/server";
import { decodeHtmlEntitiesServer } from "@/lib/html-decode";
export async function POST(request: NextRequest) {
let userMessage = "";
try {
// Rate limiting for n8n chat endpoint
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
const { checkRateLimit } = await import('@/lib/auth');
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute for chat
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
const json = await request.json();
userMessage = json.message;
const history = json.history || [];
if (!userMessage || typeof userMessage !== "string") {
return NextResponse.json(
{ error: "Message is required" },
{ status: 400 },
);
}
// Call your n8n chat webhook
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl || n8nWebhookUrl.trim() === '') {
console.error("N8N_WEBHOOK_URL not configured. Environment check:", {
hasUrl: !!process.env.N8N_WEBHOOK_URL,
urlValue: process.env.N8N_WEBHOOK_URL || '(empty)',
nodeEnv: process.env.NODE_ENV,
});
return NextResponse.json({
reply: getFallbackResponse(userMessage),
});
}
// Ensure URL doesn't have trailing slash before adding /webhook/chat
const baseUrl = n8nWebhookUrl.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/webhook/chat`;
if (process.env.NODE_ENV === 'development') {
console.log(`Sending to n8n: ${webhookUrl}`, {
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
});
}
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
try {
const response = await fetch(webhookUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
...(process.env.N8N_API_KEY && {
"X-API-Key": process.env.N8N_API_KEY,
}),
},
body: JSON.stringify({
message: userMessage,
history: history,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
if (process.env.NODE_ENV === 'development') {
console.error(`n8n webhook failed with status: ${response.status}`, {
status: response.status,
statusText: response.statusText,
error: errorText,
webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs
});
}
throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`);
}
const data = await response.json();
if (process.env.NODE_ENV === 'development') {
console.log("n8n response data (full):", JSON.stringify(data, null, 2));
console.log("n8n response data type:", typeof data);
console.log("n8n response is array:", Array.isArray(data));
}
// Try multiple ways to extract the reply
let reply: string | undefined = undefined;
// Direct fields
if (data.reply) reply = data.reply;
else if (data.message) reply = data.message;
else if (data.response) reply = data.response;
else if (data.text) reply = data.text;
else if (data.content) reply = data.content;
else if (data.answer) reply = data.answer;
else if (data.output) reply = data.output;
else if (data.result) reply = data.result;
// Array handling
else if (Array.isArray(data) && data.length > 0) {
const firstItem = data[0];
if (typeof firstItem === 'string') {
reply = firstItem;
} else if (typeof firstItem === 'object') {
reply = firstItem.reply || firstItem.message || firstItem.response ||
firstItem.text || firstItem.content || firstItem.answer ||
firstItem.output || firstItem.result;
}
}
// Nested structures (common in n8n)
else if (data && typeof data === "object") {
// Check nested data field
if (data.data) {
if (typeof data.data === 'string') {
reply = data.data;
} else if (typeof data.data === 'object') {
reply = data.data.reply || data.data.message || data.data.response ||
data.data.text || data.data.content || data.data.answer;
}
}
// Check nested json field
if (!reply && data.json) {
if (typeof data.json === 'string') {
reply = data.json;
} else if (typeof data.json === 'object') {
reply = data.json.reply || data.json.message || data.json.response ||
data.json.text || data.json.content || data.json.answer;
}
}
// Check items array (n8n often wraps in items)
if (!reply && Array.isArray(data.items) && data.items.length > 0) {
const firstItem = data.items[0];
if (typeof firstItem === 'string') {
reply = firstItem;
} else if (typeof firstItem === 'object') {
reply = firstItem.reply || firstItem.message || firstItem.response ||
firstItem.text || firstItem.content || firstItem.answer ||
firstItem.json?.reply || firstItem.json?.message;
}
}
// Last resort: if it's a single string value object, try to extract
if (!reply && Object.keys(data).length === 1) {
const value = Object.values(data)[0];
if (typeof value === 'string') {
reply = value;
}
}
// If still no reply but data exists, stringify it (for debugging)
if (!reply && Object.keys(data).length > 0) {
console.warn("n8n response structure not recognized, attempting to extract any string value");
// Try to find any string value in the object
const findStringValue = (obj: unknown): string | undefined => {
if (typeof obj === 'string' && obj.length > 0) return obj;
if (Array.isArray(obj) && obj.length > 0) {
return findStringValue(obj[0]);
}
if (obj && typeof obj === 'object' && obj !== null) {
const objRecord = obj as Record<string, unknown>;
for (const key of ['reply', 'message', 'response', 'text', 'content', 'answer', 'output', 'result']) {
if (objRecord[key] && typeof objRecord[key] === 'string') {
return objRecord[key] as string;
}
}
// Recursively search
for (const value of Object.values(objRecord)) {
const found = findStringValue(value);
if (found) return found;
}
}
return undefined;
};
reply = findStringValue(data);
}
}
if (!reply) {
console.error("n8n response missing reply field. Full response:", JSON.stringify(data, null, 2));
throw new Error("Invalid response format from n8n - no reply field found");
}
// Decode HTML entities in the reply
const decodedReply = decodeHtmlEntitiesServer(String(reply));
return NextResponse.json({
reply: decodedReply,
});
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n webhook request timed out");
} else {
console.error("n8n webhook fetch error:", fetchError);
}
throw fetchError;
}
} catch (error: unknown) {
console.error("Chat API error:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? `configured (${process.env.N8N_WEBHOOK_URL})` : 'missing',
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
nodeEnv: process.env.NODE_ENV,
});
// Fallback to mock responses
// Now using the variable captured at the start
return NextResponse.json({ reply: getFallbackResponse(userMessage) });
}
}
function getFallbackResponse(message: string): string {
if (!message || typeof message !== "string") {
return "I'm having a bit of trouble understanding. Could you try asking again?";
}
const lowerMessage = message.toLowerCase();
if (
lowerMessage.includes("skill") ||
lowerMessage.includes("tech") ||
lowerMessage.includes("stack")
) {
return "I specialize in full-stack development with Next.js, React, and Flutter for mobile. On the DevOps side, I love working with Docker Swarm, Traefik, and CI/CD pipelines. Basically, if it involves code or servers, I'm interested!";
}
if (
lowerMessage.includes("project") ||
lowerMessage.includes("built") ||
lowerMessage.includes("work")
) {
return "One of my key projects is Clarity, a Flutter app designed to help people with dyslexia. I also maintain a comprehensive self-hosted infrastructure with Docker Swarm. You can check out more details in the Projects section!";
}
if (
lowerMessage.includes("contact") ||
lowerMessage.includes("email") ||
lowerMessage.includes("reach") ||
lowerMessage.includes("hire")
) {
return "The best way to reach me is through the contact form below or by emailing contact@dk0.dev. I'm always open to discussing new ideas, opportunities, or just chatting about tech!";
}
if (
lowerMessage.includes("location") ||
lowerMessage.includes("where") ||
lowerMessage.includes("live")
) {
return "I'm based in Osnabrück, Germany. It's a great place to be a student and work on tech projects!";
}
if (
lowerMessage.includes("hobby") ||
lowerMessage.includes("free time") ||
lowerMessage.includes("fun")
) {
return "When I'm not coding or tweaking my servers, I enjoy gaming, going for a jog, or experimenting with new tech. Fun fact: I still use pen and paper for my calendar, even though I automate everything else!";
}
if (
lowerMessage.includes("devops") ||
lowerMessage.includes("docker") ||
lowerMessage.includes("server") ||
lowerMessage.includes("hosting")
) {
return "I'm really into DevOps! I run my own infrastructure on IONOS and OVHcloud using Docker Swarm and Traefik. It allows me to host various services and game servers efficiently while learning a ton about system administration.";
}
if (
lowerMessage.includes("student") ||
lowerMessage.includes("study") ||
lowerMessage.includes("education")
) {
return "Yes, I'm currently a student in Osnabrück. I balance my studies with working on personal projects and managing my self-hosted infrastructure. It keeps me busy but I learn something new every day!";
}
if (
lowerMessage.includes("hello") ||
lowerMessage.includes("hi ") ||
lowerMessage.includes("hey")
) {
return "Hi there! I'm Dennis's AI assistant (currently in offline mode). How can I help you learn more about Dennis today?";
}
// Default response
return "That's an interesting question! I'm currently operating in fallback mode, so my knowledge is a bit limited right now. But I can tell you that Dennis is a full-stack developer and DevOps enthusiast who loves building with Next.js and Docker. Feel free to ask about his skills, projects, or how to contact him!";
}

View File

@@ -0,0 +1,271 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
/**
* POST /api/n8n/generate-image
*
* Triggers AI image generation for a project via n8n workflow
*
* Body:
* {
* projectId: number;
* regenerate?: boolean; // Force regenerate even if image exists
* }
*/
export async function POST(req: NextRequest) {
try {
// Rate limiting for n8n endpoints
const ip = req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || 'unknown';
const { checkRateLimit } = await import('@/lib/auth');
if (!checkRateLimit(ip, 10, 60000)) { // 10 requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
// Require admin authentication for n8n endpoints
const { requireAdminAuth } = await import('@/lib/auth');
const authError = requireAdminAuth(req);
if (authError) {
return authError;
}
const body = await req.json();
const { projectId, regenerate = false } = body;
// Validate input
if (!projectId) {
return NextResponse.json(
{ error: "projectId is required" },
{ status: 400 },
);
}
// Check environment variables
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
const n8nSecretToken = process.env.N8N_SECRET_TOKEN;
if (!n8nWebhookUrl) {
return NextResponse.json(
{
error: "N8N_WEBHOOK_URL not configured",
message:
"AI image generation is not set up. Please configure n8n webhooks.",
},
{ status: 503 },
);
}
const projectIdNum = typeof projectId === "string" ? parseInt(projectId, 10) : Number(projectId);
if (!Number.isFinite(projectIdNum)) {
return NextResponse.json({ error: "projectId must be a number" }, { status: 400 });
}
// Fetch project data directly (avoid HTTP self-calls)
const project = await prisma.project.findUnique({ where: { id: projectIdNum } });
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
// Optional: Check if project already has an image
if (!regenerate) {
if (project.imageUrl && project.imageUrl !== "") {
return NextResponse.json(
{
success: true,
message:
"Project already has an image. Use regenerate=true to force regeneration.",
projectId: projectIdNum,
existingImageUrl: project.imageUrl,
regenerated: false,
},
{ status: 200 },
);
}
}
// Call n8n webhook to trigger AI image generation
// New webhook expects: body.projectData with title, category, description
// Webhook path: /webhook/image-gen (instead of /webhook/ai-image-generation)
const n8nResponse = await fetch(
`${n8nWebhookUrl}/webhook/image-gen`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...(n8nSecretToken && {
Authorization: `Bearer ${n8nSecretToken}`,
}),
},
body: JSON.stringify({
projectId: projectIdNum,
projectData: {
title: project.title || "Unknown Project",
category: project.category || "Technology",
description: project.description || "A clean minimalist visualization",
},
regenerate: regenerate,
triggeredBy: "api",
timestamp: new Date().toISOString(),
}),
},
);
if (!n8nResponse.ok) {
const errorText = await n8nResponse.text();
console.error("n8n webhook error:", errorText);
return NextResponse.json(
{
error: "Failed to trigger image generation",
message: "n8n workflow failed to execute",
details: errorText,
},
{ status: 500 },
);
}
// The new webhook should return JSON with the pollinations.ai image URL
// The pollinations.ai URL format is: https://image.pollinations.ai/prompt/...
// This URL is stable and can be used directly
const contentType = n8nResponse.headers.get("content-type");
let imageUrl: string;
let generatedAt: string;
let fileSize: string | undefined;
if (contentType?.includes("application/json")) {
const result = await n8nResponse.json();
// Handle JSON response - webhook should return the pollinations.ai URL
// The URL from pollinations.ai is the direct image URL
imageUrl = result.imageUrl || result.url || result.generatedPrompt || "";
// If the webhook returns the pollinations.ai URL directly, use it
// Format: https://image.pollinations.ai/prompt/...
if (!imageUrl && typeof result === 'string' && result.includes('pollinations.ai')) {
imageUrl = result;
}
generatedAt = result.generatedAt || new Date().toISOString();
fileSize = result.fileSize;
} else if (contentType?.startsWith("image/")) {
// If webhook returns image binary, we need the URL from the workflow
// For pollinations.ai, the URL should be constructed from the prompt
// But ideally the webhook should return JSON with the URL
return NextResponse.json(
{
error: "Webhook returned image binary instead of URL",
message: "Please modify the n8n workflow to return JSON with the imageUrl field containing the pollinations.ai URL",
},
{ status: 500 },
);
} else {
// Try to parse as text/URL
const textResponse = await n8nResponse.text();
if (textResponse.includes('pollinations.ai') || textResponse.startsWith('http')) {
imageUrl = textResponse.trim();
generatedAt = new Date().toISOString();
} else {
return NextResponse.json(
{
error: "Unexpected response format from webhook",
message: "Webhook should return JSON with imageUrl field containing the pollinations.ai URL",
},
{ status: 500 },
);
}
}
if (!imageUrl) {
return NextResponse.json(
{
error: "No image URL returned from webhook",
message: "The n8n workflow should return the pollinations.ai image URL in the response",
},
{ status: 500 },
);
}
// If we got an image URL, we should update the project with it
if (imageUrl) {
try {
await prisma.project.update({
where: { id: projectIdNum },
data: { imageUrl, updatedAt: new Date() },
});
} catch {
// Non-fatal: image URL can still be returned to caller
console.warn("Failed to update project with image URL");
}
}
return NextResponse.json(
{
success: true,
message: "AI image generation completed successfully",
projectId: projectIdNum,
imageUrl: imageUrl,
generatedAt: generatedAt,
fileSize: fileSize,
regenerated: regenerate,
},
{ status: 200 },
);
} catch (error) {
console.error("Error in generate-image API:", error);
return NextResponse.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
}
/**
* GET /api/n8n/generate-image?projectId=123
*
* Check the status of image generation for a project
*/
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams;
const projectId = searchParams.get("projectId");
if (!projectId) {
return NextResponse.json(
{ error: "projectId query parameter is required" },
{ status: 400 },
);
}
const projectIdNum = parseInt(projectId, 10);
if (!Number.isFinite(projectIdNum)) {
return NextResponse.json({ error: "projectId must be a number" }, { status: 400 });
}
const project = await prisma.project.findUnique({ where: { id: projectIdNum } });
if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
return NextResponse.json({
projectId: projectIdNum,
title: project.title,
hasImage: !!project.imageUrl,
imageUrl: project.imageUrl || null,
updatedAt: project.updatedAt,
});
} catch (error) {
console.error("Error checking image status:", error);
return NextResponse.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,133 @@
// app/api/n8n/hardcover/currently-reading/route.ts
import { NextRequest, NextResponse } from "next/server";
// Cache für 5 Minuten, damit wir n8n nicht zuspammen
// Hardcover-Daten ändern sich nicht so häufig
export const revalidate = 300;
export async function GET(request: NextRequest) {
// Rate limiting for n8n hardcover endpoint
const ip =
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown";
const ua = request.headers.get("user-agent") || "unknown";
const { checkRateLimit } = await import('@/lib/auth');
// In dev, many requests can share ip=unknown; use UA to avoid a shared bucket.
const rateKey =
process.env.NODE_ENV === "development" && ip === "unknown"
? `ua:${ua.slice(0, 120)}`
: ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
try {
// Check if n8n webhook URL is configured
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
console.warn("N8N_WEBHOOK_URL not configured for hardcover endpoint");
// Return fallback if n8n is not configured
return NextResponse.json({
currentlyReading: null,
});
}
// Rufe den n8n Webhook auf
// Add timestamp to query to bypass Cloudflare cache
const webhookUrl = `${n8nWebhookUrl}/webhook/hardcover/currently-reading?t=${Date.now()}`;
if (process.env.NODE_ENV === 'development') {
console.log(`Fetching currently reading from: ${webhookUrl}`);
}
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
try {
const res = await fetch(webhookUrl, {
method: "GET",
headers: {
Accept: "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
...(process.env.N8N_API_KEY && {
"X-API-Key": process.env.N8N_API_KEY,
}),
},
next: { revalidate: 300 },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error');
console.error(`n8n hardcover webhook failed: ${res.status}`, errorText);
throw new Error(`n8n error: ${res.status} - ${errorText}`);
}
const raw = await res.text().catch(() => "");
if (!raw || !raw.trim()) {
throw new Error("Empty response body received from n8n");
}
let data: unknown;
try {
data = JSON.parse(raw);
} catch (_parseError) {
// Sometimes upstream sends HTML or a partial response; include a snippet for debugging.
const snippet = raw.slice(0, 240);
throw new Error(
`Invalid JSON from n8n (${res.status}): ${snippet}${raw.length > 240 ? "…" : ""}`,
);
}
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
const readingData = Array.isArray(data) ? data[0] : data;
// Safety check: if readingData is still undefined/null (e.g. empty array), use fallback
if (!readingData) {
throw new Error("Empty data received from n8n");
}
// Ensure currentlyReading has proper structure
if (readingData.currentlyReading && typeof readingData.currentlyReading === "object") {
// Already properly formatted from n8n
} else if (readingData.currentlyReading === null || readingData.currentlyReading === undefined) {
// No reading data - keep as null
readingData.currentlyReading = null;
}
return NextResponse.json(readingData);
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n hardcover webhook request timed out");
} else {
console.error("n8n hardcover webhook fetch error:", fetchError);
}
throw fetchError;
}
} catch (error: unknown) {
console.error("Error fetching n8n hardcover data:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? 'configured' : 'missing',
});
// Leeres Fallback-Objekt, damit die Seite nicht abstürzt
return NextResponse.json({
currentlyReading: null,
});
}
}

143
app/api/n8n/status/route.ts Normal file
View File

@@ -0,0 +1,143 @@
// app/api/n8n/status/route.ts
import { NextRequest, NextResponse } from "next/server";
// Cache für 30 Sekunden, damit wir n8n nicht zuspammen
export const revalidate = 30;
export async function GET(request: NextRequest) {
// Rate limiting for n8n status endpoint
const ip =
request.headers.get("x-forwarded-for") ||
request.headers.get("x-real-ip") ||
"unknown";
const ua = request.headers.get("user-agent") || "unknown";
const { checkRateLimit } = await import('@/lib/auth');
// In dev, many requests can share ip=unknown; use UA to avoid a shared bucket.
const rateKey =
process.env.NODE_ENV === "development" && ip === "unknown"
? `ua:${ua.slice(0, 120)}`
: ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
);
}
try {
// Check if n8n webhook URL is configured
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
if (process.env.NODE_ENV === 'development') {
console.warn("N8N_WEBHOOK_URL not configured for status endpoint");
}
// Return fallback if n8n is not configured
return NextResponse.json({
status: { text: "offline", color: "gray" },
music: null,
gaming: null,
coding: null,
});
}
// Rufe den n8n Webhook auf
// Add timestamp to query to bypass Cloudflare cache
const statusUrl = `${n8nWebhookUrl}/webhook/denshooter-71242/status?t=${Date.now()}`;
if (process.env.NODE_ENV === 'development') {
console.log(`Fetching status from: ${statusUrl}`);
}
// Add timeout to prevent hanging requests
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
try {
const res = await fetch(statusUrl, {
method: "GET",
headers: {
// n8n sometimes responds with empty body; we'll parse defensively below.
Accept: "application/json",
...(process.env.N8N_SECRET_TOKEN && {
Authorization: `Bearer ${process.env.N8N_SECRET_TOKEN}`,
}),
},
next: { revalidate: 30 },
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error');
if (process.env.NODE_ENV === 'development') {
console.error(`n8n status webhook failed: ${res.status}`, errorText);
}
throw new Error(`n8n error: ${res.status} - ${errorText}`);
}
const raw = await res.text().catch(() => "");
if (!raw || !raw.trim()) {
throw new Error("Empty response body received from n8n");
}
let data: unknown;
try {
data = JSON.parse(raw);
} catch (_parseError) {
// Sometimes upstream sends HTML or a partial response; include a snippet for debugging.
const snippet = raw.slice(0, 240);
throw new Error(
`Invalid JSON from n8n (${res.status}): ${snippet}${raw.length > 240 ? "…" : ""}`,
);
}
// n8n gibt oft ein Array zurück: [{...}]. Wir wollen nur das Objekt.
const statusData = Array.isArray(data) ? data[0] : data;
// Safety check: if statusData is still undefined/null (e.g. empty array), use fallback
if (!statusData) {
throw new Error("Empty data received from n8n");
}
// Ensure coding object has proper structure
if (statusData.coding && typeof statusData.coding === "object") {
// Already properly formatted from n8n
} else if (statusData.coding === null || statusData.coding === undefined) {
// No coding data - keep as null
statusData.coding = null;
}
return NextResponse.json(statusData);
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (process.env.NODE_ENV === 'development') {
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n status webhook request timed out");
} else {
console.error("n8n status webhook fetch error:", fetchError);
}
}
throw fetchError;
}
} catch (error: unknown) {
if (process.env.NODE_ENV === 'development') {
console.error("Error fetching n8n status:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
n8nUrl: process.env.N8N_WEBHOOK_URL ? 'configured' : 'missing',
});
}
// Leeres Fallback-Objekt, damit die Seite nicht abstürzt
return NextResponse.json({
status: { text: "offline", color: "gray" },
music: null,
gaming: null,
coding: null,
});
}
}

View File

@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { apiCache } from '@/lib/cache';
import { checkRateLimit, getRateLimitHeaders, requireSessionAuth } from '@/lib/auth';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { generateUniqueSlug } from '@/lib/slug';
export async function GET(
request: NextRequest,
@@ -9,6 +12,9 @@ export async function GET(
try {
const { id: idParam } = await params;
const id = parseInt(idParam);
if (!Number.isFinite(id)) {
return NextResponse.json({ error: 'Invalid project id' }, { status: 400 });
}
const project = await prisma.project.findUnique({
where: { id }
@@ -23,7 +29,20 @@ export async function GET(
return NextResponse.json(project);
} catch (error) {
console.error('Error fetching project:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Project table does not exist. Returning 404.');
}
return NextResponse.json(
{ error: 'Project not found' },
{ status: 404 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error fetching project:', error);
}
return NextResponse.json(
{ error: 'Failed to fetch project' },
{ status: 500 }
@@ -36,6 +55,21 @@ export async function PUT(
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Rate limiting for PUT requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 requests per minute for PUT
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
}
);
}
// Check if this is an admin request
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
@@ -44,18 +78,48 @@ export async function PUT(
{ status: 403 }
);
}
const authError = requireSessionAuth(request);
if (authError) return authError;
const { id: idParam } = await params;
const id = parseInt(idParam);
if (!Number.isFinite(id)) {
return NextResponse.json({ error: 'Invalid project id' }, { status: 400 });
}
const data = await request.json();
// Remove difficulty field if it exists (since we're removing it)
const { difficulty, ...projectData } = data;
const { difficulty, slug, defaultLocale, ...projectData } = data;
// Keep slug stable by default; only update if explicitly provided,
// or if the project currently has no slug (e.g. after migration).
const existing = await prisma.project.findUnique({
where: { id },
select: { slug: true, title: true },
});
const nextSlug =
typeof slug === 'string' && slug.trim()
? slug.trim()
: existing?.slug?.trim()
? existing.slug
: await generateUniqueSlug({
base: String(projectData.title || existing?.title || 'project'),
isTaken: async (candidate) => {
const found = await prisma.project.findUnique({
where: { slug: candidate },
select: { id: true },
});
return !!found && found.id !== id;
},
});
const project = await prisma.project.update({
where: { id },
data: {
...projectData,
slug: nextSlug,
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
updatedAt: new Date(),
// Keep existing difficulty if not provided
...(difficulty ? { difficulty } : {})
@@ -68,7 +132,20 @@ export async function PUT(
return NextResponse.json(project);
} catch (error) {
console.error('Error updating project:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Project table does not exist.');
}
return NextResponse.json(
{ error: 'Database table not found. Please run migrations.' },
{ status: 503 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error updating project:', error);
}
return NextResponse.json(
{ error: 'Failed to update project', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
@@ -81,8 +158,37 @@ export async function DELETE(
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Rate limiting for DELETE requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 3, 60000)) { // 3 requests per minute for DELETE (more restrictive)
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 3, 60000)
}
}
);
}
// Check if this is an admin request
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
return NextResponse.json(
{ error: 'Admin access required' },
{ status: 403 }
);
}
const authError = requireSessionAuth(request);
if (authError) return authError;
const { id: idParam } = await params;
const id = parseInt(idParam);
if (!Number.isFinite(id)) {
return NextResponse.json({ error: 'Invalid project id' }, { status: 400 });
}
await prisma.project.delete({
where: { id }
@@ -94,7 +200,20 @@ export async function DELETE(
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting project:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Project table does not exist.');
}
return NextResponse.json(
{ error: 'Database table not found. Please run migrations.' },
{ status: 503 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error deleting project:', error);
}
return NextResponse.json(
{ error: 'Failed to delete project' },
{ status: 500 }

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSessionAuth } from "@/lib/auth";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const { id: idParam } = await params;
const id = parseInt(idParam, 10);
if (!Number.isFinite(id)) return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
const { searchParams } = new URL(request.url);
const locale = searchParams.get("locale") || "en";
const translation = await prisma.projectTranslation.findFirst({
where: { projectId: id, locale },
});
return NextResponse.json({ translation });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) return NextResponse.json({ error: "Admin access required" }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
const { id: idParam } = await params;
const id = parseInt(idParam, 10);
if (!Number.isFinite(id)) return NextResponse.json({ error: "Invalid project id" }, { status: 400 });
const body = (await request.json()) as {
locale?: string;
title?: string;
description?: string;
content?: string;
};
const locale = body.locale || "en";
const title = body.title?.trim();
const description = body.description?.trim();
const content = typeof body.content === "string" ? body.content.trim() : undefined;
if (!title || !description) {
return NextResponse.json({ error: "title and description are required" }, { status: 400 });
}
const saved = await prisma.projectTranslation.upsert({
where: { projectId_locale: { projectId: id, locale } },
create: {
projectId: id,
locale,
title,
description,
content: content ?? undefined,
},
update: {
title,
description,
content: content ?? undefined,
},
});
return NextResponse.json({ translation: saved });
}

View File

@@ -1,18 +1,47 @@
import { NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { NextRequest, NextResponse } from 'next/server';
import { prisma, projectService } from '@/lib/prisma';
import { requireSessionAuth } from '@/lib/auth';
export async function GET() {
export async function GET(request: NextRequest) {
try {
// Get all projects with full data
const projectsResult = await projectService.getAllProjects();
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
// Projects (with translations)
const projectsResult = await projectService.getAllProjects({ limit: 10000 });
const projects = projectsResult.projects || projectsResult;
const projectIds = projects.map((p: { id: number }) => p.id);
const projectTranslations = await prisma.projectTranslation.findMany({
where: { projectId: { in: projectIds } },
orderBy: [{ projectId: 'asc' }, { locale: 'asc' }],
});
// CMS content pages (with translations)
const contentPages = await prisma.contentPage.findMany({
orderBy: { key: 'asc' },
include: {
translations: {
orderBy: { locale: 'asc' },
},
},
});
const siteSettings = await prisma.siteSettings.findUnique({ where: { id: 1 } });
// Format for export
const exportData = {
version: '1.0',
version: '2.0',
exportDate: new Date().toISOString(),
siteSettings,
contentPages,
projectTranslations,
projects: projects.map(project => ({
id: project.id,
slug: (project as unknown as { slug?: string }).slug,
defaultLocale: (project as unknown as { defaultLocale?: string }).defaultLocale,
title: project.title,
description: project.description,
content: project.content,

View File

@@ -1,76 +1,309 @@
import { NextRequest, NextResponse } from 'next/server';
import { projectService } from '@/lib/prisma';
import { NextRequest, NextResponse } from "next/server";
import { prisma, projectService } from "@/lib/prisma";
import { requireSessionAuth } from "@/lib/auth";
import type { Prisma } from "@prisma/client";
type ImportSiteSettings = {
defaultLocale?: unknown;
locales?: unknown;
theme?: unknown;
};
type ImportContentPageTranslation = {
locale?: unknown;
title?: unknown;
slug?: unknown;
content?: unknown;
metaDescription?: unknown;
keywords?: unknown;
};
type ImportContentPage = {
key?: unknown;
status?: unknown;
translations?: unknown;
};
type ImportProject = {
id?: unknown;
slug?: unknown;
defaultLocale?: unknown;
title?: unknown;
description?: unknown;
content?: unknown;
tags?: unknown;
category?: unknown;
featured?: unknown;
github?: unknown;
live?: unknown;
published?: unknown;
imageUrl?: unknown;
difficulty?: unknown;
timeToComplete?: unknown;
technologies?: unknown;
challenges?: unknown;
lessonsLearned?: unknown;
futureImprovements?: unknown;
demoVideo?: unknown;
screenshots?: unknown;
colorScheme?: unknown;
accessibility?: unknown;
performance?: unknown;
analytics?: unknown;
};
type ImportProjectTranslation = {
projectId?: unknown;
locale?: unknown;
title?: unknown;
description?: unknown;
content?: unknown;
metaDescription?: unknown;
keywords?: unknown;
ogImage?: unknown;
schema?: unknown;
};
type ImportPayload = {
projects?: unknown;
siteSettings?: unknown;
contentPages?: unknown;
projectTranslations?: unknown;
};
function asString(v: unknown): string | null {
return typeof v === "string" ? v : null;
}
function asStringArray(v: unknown): string[] | null {
if (!Array.isArray(v)) return null;
const allStrings = v.filter((x) => typeof x === "string") as string[];
return allStrings.length === v.length ? allStrings : null;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const isAdminRequest = request.headers.get("x-admin-request") === "true";
if (!isAdminRequest) {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
}
const authError = requireSessionAuth(request);
if (authError) return authError;
const body = (await request.json()) as ImportPayload;
// Validate import data structure
if (!body.projects || !Array.isArray(body.projects)) {
if (!Array.isArray(body.projects)) {
return NextResponse.json(
{ error: 'Invalid import data format' },
{ status: 400 }
{ error: "Invalid import data format" },
{ status: 400 },
);
}
const results = {
imported: 0,
skipped: 0,
errors: [] as string[]
errors: [] as string[],
};
// Import SiteSettings (optional)
if (body.siteSettings && typeof body.siteSettings === "object") {
try {
const ss = body.siteSettings as ImportSiteSettings;
const defaultLocale = asString(ss.defaultLocale);
const locales = asStringArray(ss.locales);
const theme = ss.theme as Prisma.InputJsonValue | undefined;
await prisma.siteSettings.upsert({
where: { id: 1 },
create: {
id: 1,
...(defaultLocale ? { defaultLocale } : {}),
...(locales ? { locales } : {}),
...(theme ? { theme } : {}),
},
update: {
...(defaultLocale ? { defaultLocale } : {}),
...(locales ? { locales } : {}),
...(theme ? { theme } : {}),
},
});
} catch {
// non-blocking
}
}
// Import CMS content pages (optional)
if (Array.isArray(body.contentPages)) {
for (const page of body.contentPages) {
try {
const key = asString((page as ImportContentPage)?.key);
if (!key) continue;
const statusRaw = asString((page as ImportContentPage)?.status);
const status = statusRaw === "DRAFT" || statusRaw === "PUBLISHED" ? statusRaw : "PUBLISHED";
const upserted = await prisma.contentPage.upsert({
where: { key },
create: { key, status },
update: { status },
});
const translations = (page as ImportContentPage)?.translations;
if (Array.isArray(translations)) {
for (const tr of translations as ImportContentPageTranslation[]) {
const locale = asString(tr?.locale);
if (!locale || typeof tr?.content === "undefined" || tr?.content === null) continue;
await prisma.contentPageTranslation.upsert({
where: { pageId_locale: { pageId: upserted.id, locale } },
create: {
pageId: upserted.id,
locale,
title: asString(tr.title),
slug: asString(tr.slug),
content: tr.content as Prisma.InputJsonValue,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
},
update: {
title: asString(tr.title),
slug: asString(tr.slug),
content: tr.content as Prisma.InputJsonValue,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
},
});
}
}
} catch (error) {
const key = asString((page as ImportContentPage)?.key) ?? "unknown";
results.errors.push(
`Failed to import content page "${key}": ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
}
// Preload existing titles once (avoid O(n^2) DB reads during import)
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
const existingTitles = new Set(existingProjects.map(p => p.title));
const existingSlugs = new Set(
existingProjects
.map((p) => (p as unknown as { slug?: string }).slug)
.filter((s): s is string => typeof s === "string" && s.length > 0),
);
// Process each project
for (const projectData of body.projects) {
for (const projectData of body.projects as ImportProject[]) {
try {
// Check if project already exists (by title)
const existingProjectsResult = await projectService.getAllProjects();
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
const exists = existingProjects.some(p => p.title === projectData.title);
const title = asString(projectData.title);
if (!title) continue;
const exists = existingTitles.has(title);
if (exists) {
results.skipped++;
results.errors.push(`Project "${projectData.title}" already exists`);
results.errors.push(`Project "${title}" already exists`);
continue;
}
// Create new project
await projectService.createProject({
title: projectData.title,
description: projectData.description,
content: projectData.content,
tags: projectData.tags || [],
category: projectData.category,
featured: projectData.featured || false,
github: projectData.github,
live: projectData.live,
const created = await projectService.createProject({
slug: asString(projectData.slug) ?? undefined,
defaultLocale: asString(projectData.defaultLocale) ?? "en",
title,
description: asString(projectData.description) ?? "",
content: projectData.content as Prisma.InputJsonValue | undefined,
tags: (asStringArray(projectData.tags) ?? []) as string[],
category: asString(projectData.category) ?? "General",
featured: projectData.featured === true,
github: asString(projectData.github) ?? undefined,
live: asString(projectData.live) ?? undefined,
published: projectData.published !== false, // Default to true
imageUrl: projectData.imageUrl,
difficulty: projectData.difficulty || 'Intermediate',
timeToComplete: projectData.timeToComplete,
technologies: projectData.technologies || [],
challenges: projectData.challenges || [],
lessonsLearned: projectData.lessonsLearned || [],
futureImprovements: projectData.futureImprovements || [],
demoVideo: projectData.demoVideo,
screenshots: projectData.screenshots || [],
colorScheme: projectData.colorScheme || 'Dark',
imageUrl: asString(projectData.imageUrl) ?? undefined,
difficulty: asString(projectData.difficulty) ?? "Intermediate",
timeToComplete: asString(projectData.timeToComplete) ?? undefined,
technologies: (asStringArray(projectData.technologies) ?? []) as string[],
challenges: (asStringArray(projectData.challenges) ?? []) as string[],
lessonsLearned: (asStringArray(projectData.lessonsLearned) ?? []) as string[],
futureImprovements: (asStringArray(projectData.futureImprovements) ?? []) as string[],
demoVideo: asString(projectData.demoVideo) ?? undefined,
screenshots: (asStringArray(projectData.screenshots) ?? []) as string[],
colorScheme: asString(projectData.colorScheme) ?? "Dark",
accessibility: projectData.accessibility !== false, // Default to true
performance: projectData.performance || {
performance: (projectData.performance as Record<string, unknown> | null) || {
lighthouse: 0,
bundleSize: '0KB',
loadTime: '0s'
bundleSize: "0KB",
loadTime: "0s",
},
analytics: projectData.analytics || {
analytics: (projectData.analytics as Record<string, unknown> | null) || {
views: 0,
likes: 0,
shares: 0
}
shares: 0,
},
});
// Import translations (optional, from export v2)
if (Array.isArray(body.projectTranslations)) {
for (const tr of body.projectTranslations as ImportProjectTranslation[]) {
const projectId = typeof tr?.projectId === "number" ? tr.projectId : null;
const locale = asString(tr?.locale);
if (!projectId || !locale) continue;
// Map translation to created project by original slug/title when possible.
// We match by slug if available in exported project list; otherwise by title.
const exportedProject = (body.projects as ImportProject[]).find(
(p) => typeof p.id === "number" && p.id === projectId,
);
const exportedSlug = asString(exportedProject?.slug);
const matches =
(exportedSlug && (created as unknown as { slug?: string }).slug === exportedSlug) ||
(!!asString(exportedProject?.title) &&
(created as unknown as { title?: string }).title === asString(exportedProject?.title));
if (!matches) continue;
const trTitle = asString(tr.title);
const trDescription = asString(tr.description);
if (!trTitle || !trDescription) continue;
await prisma.projectTranslation.upsert({
where: {
projectId_locale: {
projectId: (created as unknown as { id: number }).id,
locale,
},
},
create: {
projectId: (created as unknown as { id: number }).id,
locale,
title: trTitle,
description: trDescription,
content: (tr.content as Prisma.InputJsonValue) ?? null,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
ogImage: asString(tr.ogImage),
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
},
update: {
title: trTitle,
description: trDescription,
content: (tr.content as Prisma.InputJsonValue) ?? null,
metaDescription: asString(tr.metaDescription),
keywords: asString(tr.keywords),
ogImage: asString(tr.ogImage),
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
},
});
}
}
results.imported++;
existingTitles.add(title);
const slug = asString(projectData.slug);
if (slug) existingSlugs.add(slug);
} catch (error) {
results.skipped++;
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
const title = asString(projectData.title) ?? "unknown";
results.errors.push(
`Failed to import "${title}": ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
@@ -80,10 +313,10 @@ export async function POST(request: NextRequest) {
results
});
} catch (error) {
console.error('Import error:', error);
console.error("Import error:", error);
return NextResponse.json(
{ error: 'Failed to import projects' },
{ status: 500 }
{ error: "Failed to import projects" },
{ status: 500 },
);
}
}

View File

@@ -1,78 +1,121 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { apiCache } from '@/lib/cache';
import { requireAdminAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp } from '@/lib/auth';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { generateUniqueSlug } from '@/lib/slug';
import { getProjects as getDirectusProjects } from '@/lib/directus';
import { ProjectListItem } from '@/app/_ui/ProjectsPageClient';
export async function GET(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 10, 60000)) { // 10 requests per minute
const ip = getClientIp(request);
const rlKey = ip !== "unknown" ? ip : `dev_unknown:${request.headers.get("user-agent") || "ua"}`;
// In development we keep this very high to avoid breaking local navigation/HMR.
const max = process.env.NODE_ENV === "development" ? 300 : 60;
if (!checkRateLimit(rlKey, max, 60000)) {
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 10, 60000)
...getRateLimitHeaders(rlKey, max, 60000)
}
}
);
}
// Check admin authentication for admin endpoints
// Check session authentication for admin endpoints
const url = new URL(request.url);
if (url.pathname.includes('/manage') || request.headers.get('x-admin-request') === 'true') {
const authError = requireAdminAuth(request);
const authError = requireSessionAuth(request);
if (authError) {
return authError;
}
}
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '50');
const pageRaw = parseInt(searchParams.get('page') || '1');
const limitRaw = parseInt(searchParams.get('limit') || '50');
const page = Number.isFinite(pageRaw) && pageRaw > 0 ? pageRaw : 1;
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
const category = searchParams.get('category');
const featured = searchParams.get('featured');
const published = searchParams.get('published');
const published = searchParams.get('published') === 'false' ? false : true; // Default to true if not specified
const difficulty = searchParams.get('difficulty');
const search = searchParams.get('search');
const locale = searchParams.get('locale') || 'en';
// Create cache parameters object
const cacheParams = {
page: page.toString(),
limit: limit.toString(),
category,
featured,
published,
difficulty,
search
};
// Try Directus FIRST (Primary Source)
let directusProjects: ProjectListItem[] = [];
let directusSuccess = false;
try {
const fetched = await getDirectusProjects(locale, {
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
published: published,
category: category || undefined,
difficulty: difficulty || undefined,
search: search || undefined,
limit
});
// Check cache first
const cached = await apiCache.getProjects(cacheParams);
if (cached && !search) { // Don't cache search results
return NextResponse.json(cached);
if (fetched) {
directusProjects = fetched.map(p => ({
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
slug: p.slug,
title: p.title,
description: p.description,
tags: p.tags || [],
category: p.category || '',
date: p.created_at,
createdAt: p.created_at,
imageUrl: p.image_url,
}));
directusSuccess = true;
}
} catch {
console.log('Directus error, continuing with PostgreSQL fallback');
}
// If Directus returned projects, use them EXCLUSIVELY to avoid showing un-synced local data
if (directusSuccess && directusProjects.length > 0) {
return NextResponse.json({
projects: directusProjects,
total: directusProjects.length,
source: 'directus'
});
}
// Fallback 1: Try PostgreSQL only if Directus failed or is empty
try {
await prisma.$queryRaw`SELECT 1`;
} catch {
console.log('PostgreSQL not available');
return NextResponse.json({
projects: directusProjects, // Might be empty
total: directusProjects.length,
source: 'directus-empty'
});
}
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (category) where.category = category;
if (featured !== null) where.featured = featured === 'true';
if (published !== null) where.published = published === 'true';
where.published = published;
if (difficulty) where.difficulty = difficulty;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
{ tags: { hasSome: [search] } }
];
}
const [projects, total] = await Promise.all([
const [dbProjects, total] = await Promise.all([
prisma.project.findMany({
where,
orderBy: { createdAt: 'desc' },
@@ -82,21 +125,48 @@ export async function GET(request: NextRequest) {
prisma.project.count({ where })
]);
const result = {
projects,
total,
pages: Math.ceil(total / limit),
currentPage: page
};
// Cache the result (only for non-search queries)
if (!search) {
await apiCache.setProjects(cacheParams, result);
// Merge logic
const dbSlugs = new Set(dbProjects.map(p => p.slug));
const mergedProjects: ProjectListItem[] = dbProjects.map(p => ({
id: p.id,
slug: p.slug,
title: p.title,
description: p.description,
tags: p.tags,
category: p.category,
date: p.date,
createdAt: p.createdAt.toISOString(),
imageUrl: p.imageUrl,
}));
for (const dp of directusProjects) {
if (!dbSlugs.has(dp.slug)) {
mergedProjects.push(dp);
}
}
return NextResponse.json(result);
return NextResponse.json({
projects: mergedProjects,
total: total + (mergedProjects.length - dbProjects.length),
source: 'merged'
});
} catch (error) {
console.error('Error fetching projects:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Project table does not exist. Returning empty result.');
}
return NextResponse.json({
projects: [],
total: 0,
pages: 0,
currentPage: 1
});
}
if (process.env.NODE_ENV === 'development') {
console.error('Error fetching projects:', error);
}
return NextResponse.json(
{ error: 'Failed to fetch projects' },
{ status: 500 }
@@ -106,6 +176,21 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
// Rate limiting for POST requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 requests per minute for POST
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
}
);
}
// Check if this is an admin request
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) {
@@ -114,16 +199,34 @@ export async function POST(request: NextRequest) {
{ status: 403 }
);
}
const authError = requireSessionAuth(request);
if (authError) return authError;
const data = await request.json();
// Remove difficulty field if it exists (since we're removing it)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { difficulty, ...projectData } = data;
const { difficulty, slug, defaultLocale, ...projectData } = data;
const derivedSlug =
typeof slug === 'string' && slug.trim()
? slug.trim()
: await generateUniqueSlug({
base: String(projectData.title || 'project'),
isTaken: async (candidate) => {
const existing = await prisma.project.findUnique({
where: { slug: candidate },
select: { id: true },
});
return !!existing;
},
});
const project = await prisma.project.create({
data: {
...projectData,
slug: derivedSlug,
defaultLocale: typeof defaultLocale === 'string' && defaultLocale ? defaultLocale : undefined,
// Set default difficulty since it's required in schema
difficulty: 'INTERMEDIATE',
performance: data.performance || { lighthouse: 0, bundleSize: '0KB', loadTime: '0s' },
@@ -136,7 +239,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json(project);
} catch (error) {
console.error('Error creating project:', error);
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
if (process.env.NODE_ENV === 'development') {
console.warn('Project table does not exist.');
}
return NextResponse.json(
{ error: 'Database table not found. Please run migrations.' },
{ status: 503 }
);
}
if (process.env.NODE_ENV === 'development') {
console.error('Error creating project:', error);
}
return NextResponse.json(
{ error: 'Failed to create project', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getProjects } from '@/lib/directus';
export async function GET(request: NextRequest) {
try {
@@ -7,69 +7,27 @@ export async function GET(request: NextRequest) {
const slug = searchParams.get('slug');
const search = searchParams.get('search');
const category = searchParams.get('category');
const locale = searchParams.get('locale') || 'en';
if (slug) {
// Search by slug (convert title to slug format)
const projects = await prisma.project.findMany({
where: {
published: true
},
orderBy: { createdAt: 'desc' }
});
// Use Directus instead of Prisma
const projects = await getProjects(locale, {
featured: undefined,
published: true,
category: category && category !== 'All' ? category : undefined,
search: search || undefined,
});
// Find exact match by converting titles to slugs
const foundProject = projects.find(project => {
const projectSlug = project.title.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return projectSlug === slug;
});
if (foundProject) {
return NextResponse.json({ projects: [foundProject] });
}
// If no exact match, return empty array
if (!projects) {
// Directus not available or no projects found
return NextResponse.json({ projects: [] });
}
if (search) {
// General search
const projects = await prisma.project.findMany({
where: {
published: true,
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } },
{ content: { contains: search, mode: 'insensitive' } }
]
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
// Filter by slug if provided (since Directus query doesn't support slug filter directly)
if (slug) {
const project = projects.find(p => p.slug === slug);
return NextResponse.json({ projects: project ? [project] : [] });
}
if (category && category !== 'All') {
// Filter by category
const projects = await prisma.project.findMany({
where: {
published: true,
category: category
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
}
// Return all published projects if no specific search
const projects = await prisma.project.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' }
});
return NextResponse.json({ projects });
} catch (error) {
console.error('Error searching projects:', error);

View File

@@ -0,0 +1,11 @@
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 });
}

View File

@@ -1,101 +1,22 @@
import { NextResponse } from "next/server";
interface Project {
slug: string;
updated_at?: string; // Optional timestamp for last modification
}
interface ProjectsData {
posts: Project[];
}
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // Force Node runtime
const GHOST_API_URL = process.env.GHOST_API_URL;
const GHOST_API_KEY = process.env.GHOST_API_KEY;
// Funktion, um die XML für die Sitemap zu generieren
function generateXml(sitemapRoutes: { url: string; lastModified: string }[]) {
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
const urlsetOpen =
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
const urlsetClose = "</urlset>";
const urlEntries = sitemapRoutes
.map(
(route) => `
<url>
<loc>${route.url}</loc>
<lastmod>${route.lastModified}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>`,
)
.join("");
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
}
export const runtime = "nodejs";
export async function GET() {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
// Statische Routen
const staticRoutes = [
{
url: `${baseUrl}/`,
lastModified: new Date().toISOString(),
priority: 1,
changeFreq: "weekly",
},
{
url: `${baseUrl}/legal-notice`,
lastModified: new Date().toISOString(),
priority: 0.5,
changeFreq: "yearly",
},
{
url: `${baseUrl}/privacy-policy`,
lastModified: new Date().toISOString(),
priority: 0.5,
changeFreq: "yearly",
},
];
try {
const response = await fetch(
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`,
);
if (!response.ok) {
console.error(`Failed to fetch posts: ${response.statusText}`);
return new NextResponse(generateXml(staticRoutes), {
headers: { "Content-Type": "application/xml" },
});
}
const projectsData = (await response.json()) as ProjectsData;
const projects = projectsData.posts;
// Dynamische Projekt-Routen generieren
const sitemapRoutes = projects.map((project) => {
const lastModified = project.updated_at || new Date().toISOString();
return {
url: `${baseUrl}/projects/${project.slug}`,
lastModified,
priority: 0.8,
changeFreq: "monthly",
};
});
const allRoutes = [...staticRoutes, ...sitemapRoutes];
// Rückgabe der Sitemap im XML-Format
return new NextResponse(generateXml(allRoutes), {
const entries = await getSitemapEntries();
const xml = generateSitemapXml(entries);
return new NextResponse(xml, {
headers: { "Content-Type": "application/xml" },
});
} catch (error) {
console.log("Failed to fetch posts from Ghost:", error);
// Rückgabe der statischen Routen, falls Fehler auftritt
return new NextResponse(generateXml(staticRoutes), {
console.error("Failed to generate sitemap:", error);
// Fail closed: return minimal sitemap
const xml = generateSitemapXml([]);
return new NextResponse(xml, {
status: 500,
headers: { "Content-Type": "application/xml" },
});
}

21
app/api/snippets/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSnippets } from '@/lib/directus';
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const limit = parseInt(searchParams.get('limit') || '10');
const featured = searchParams.get('featured') === 'true' ? true : undefined;
const snippets = await getSnippets(limit, featured);
return NextResponse.json(
{ snippets: snippets || [] },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (_error) {
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { getTechStack } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/**
* GET /api/tech-stack
*
* Loads Tech Stack from Directus with fallback to static data
*
* Query params:
* - locale: en or de (default: en)
*/
export async function GET(request: NextRequest) {
// Rate Limit: 60 requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 60, 60000)) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
try {
const { searchParams } = new URL(request.url);
const locale = searchParams.get('locale') || 'en';
// Try to load from Directus
const techStack = await getTechStack(locale);
if (techStack && techStack.length > 0) {
return NextResponse.json(
{ techStack, source: 'directus' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
// Fallback: return empty (component will use hardcoded fallback)
return NextResponse.json(
{ techStack: null, source: 'fallback' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error loading tech stack:', error);
}
return NextResponse.json(
{ techStack: null, error: 'Failed to load tech stack', source: 'error' },
{ status: 500 }
);
}
}

388
app/components/About.tsx Normal file
View File

@@ -0,0 +1,388 @@
"use client";
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";
import ReadBooks from "./ReadBooks";
import { motion, AnimatePresence } from "framer-motion";
import { TechStackCategory, TechStackItem, Hobby, Snippet } from "@/lib/directus";
import Link from "next/link";
import ActivityFeed from "./ActivityFeed";
import BentoChat from "./BentoChat";
import { Skeleton } from "./ui/Skeleton";
import { LucideIcon, X, Copy, Check } from "lucide-react";
const iconMap: Record<string, LucideIcon> = {
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
};
const About = () => {
const locale = useLocale();
const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
const [hobbies, setHobbies] = useState<Hobby[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
const [copied, setCopied] = useState(false);
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
fetch(`/api/content/page?key=home-about&locale=${locale}`),
fetch(`/api/tech-stack?locale=${locale}`),
fetch(`/api/hobbies?locale=${locale}`),
fetch(`/api/messages?locale=${locale}`),
fetch(`/api/snippets?limit=3&featured=true`)
]);
const cmsData = await cmsRes.json();
if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent);
const techData = await techRes.json();
if (techData?.techStack) setTechStack(techData.techStack);
const hobbiesData = await hobbiesRes.json();
if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies);
const msgData = await msgRes.json();
if (msgData?.messages) setCmsMessages(msgData.messages);
const snippetsData = await snippetsRes.json();
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
} catch (error) {
console.error("About data fetch failed:", error);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [locale]);
const copyToClipboard = (code: string) => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* 1. Large Bio Text */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-[95%]" />
<Skeleton className="h-6 w-[90%]" />
</div>
) : cmsDoc ? (
<RichTextClient doc={cmsDoc} />
) : (
<p>{t("p1")} {t("p2")}</p>
)}
</div>
<div className="pt-4 sm:pt-6 md:pt-8">
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
</div>
</div>
</div>
</motion.div>
{/* 2. Activity / Status Box */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
>
<div className="relative z-10 h-full">
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> Status
</h3>
<ActivityFeed locale={locale} />
</div>
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
</motion.div>
{/* 3. AI Chat Box */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
>
<div className="flex items-center gap-2 mb-5 sm:mb-8">
<MessageSquare className="text-liquid-purple" size={20} />
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
</div>
<div className="flex-1">
<BentoChat />
</div>
</motion.div>
{/* 4. Tech Stack */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-6">
<Skeleton className="h-3 w-20" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-24 rounded-xl" />
<Skeleton className="h-8 w-16 rounded-xl" />
<Skeleton className="h-8 w-20 rounded-xl" />
</div>
</div>
))
) : (
techStack.map((cat) => (
<div key={cat.id} className="space-y-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
<div className="flex flex-wrap gap-2">
{cat.items?.map((item: TechStackItem) => (
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
{item.name}
</span>
))}
</div>
</div>
))
)}
</div>
</motion.div>
{/* 5. Library, Gear & Snippets */}
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* Library - Larger Span */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.4 }}
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
>
<div className="relative z-10 flex flex-col h-full">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
<BookOpen className="text-liquid-purple" size={24} /> Library
</h3>
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
</Link>
</div>
<CurrentlyReading />
<div className="mt-6 flex-1">
<ReadBooks />
</div>
</div>
</motion.div>
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
{/* My Gear (Uses) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }}
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
>
<div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
<Cpu className="text-liquid-mint" size={24} /> My Gear
</h3>
<div className="grid grid-cols-2 gap-4 sm:gap-6">
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
</div>
<div className="space-y-1">
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
</div>
</div>
</div>
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.6 }}
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
>
<div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter mb-4 sm:mb-6">
<Terminal className="text-liquid-purple" size={24} /> Snippets
</h3>
<div className="space-y-3">
{isLoading ? (
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-12 rounded-xl" />)
) : snippets.length > 0 ? (
snippets.map((s) => (
<button
key={s.id}
onClick={() => setSelectedSnippet(s)}
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
>
<p className="text-[9px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
</button>
))
) : (
<p className="text-xs text-stone-500 dark:text-stone-400 italic">No snippets yet.</p>
)}
</div>
</div>
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
</Link>
</motion.div>
</div>
</div>
{/* 6. Hobbies */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }}
className="md:col-span-12"
>
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
) : (
hobbies.map((hobby) => {
const Icon = iconMap[hobby.icon] || Lightbulb;
return (
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0 sm:w-5 sm:h-5" />
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
</div>
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
{hobby.description}
</p>
</div>
)
})
)}
</div>
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
</div>
</div>
</motion.div>
</div>
</div>
{/* Snippet Modal */}
<AnimatePresence>
{selectedSnippet && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedSnippet(null)}
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
>
<div className="p-5 sm:p-8 md:p-10 overflow-y-auto">
<div className="flex justify-between items-start mb-5 sm:mb-8">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-1 sm:mb-2">{selectedSnippet.category}</p>
<h3 className="text-xl sm:text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
</div>
<button
onClick={() => setSelectedSnippet(null)}
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
>
<X size={20} />
</button>
</div>
<p className="text-sm sm:text-base text-stone-600 dark:text-stone-400 mb-5 sm:mb-8 leading-relaxed">
{selectedSnippet.description}
</p>
<div className="relative group/code">
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 flex gap-2">
<button
onClick={() => copyToClipboard(selectedSnippet.code)}
className="p-2 sm:p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
title="Copy Code"
>
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
</button>
</div>
<pre className="bg-stone-950 p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-x-auto text-xs sm:text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
<code>{selectedSnippet.code}</code>
</pre>
</div>
</div>
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
<button
onClick={() => setSelectedSnippet(null)}
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
Close Laboratory
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</section>
);
};
export default About;

View File

@@ -0,0 +1,238 @@
"use client";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
interface CustomActivity {
[key: string]: unknown;
}
interface StatusData {
music: { isPlaying: boolean; track: string; artist: string; albumArt: string; url: string; } | null;
gaming: { isPlaying: boolean; name: string; image: string | null; state?: string | number; details?: string | number; } | null;
coding: { isActive: boolean; project?: string; file?: string; language?: string; } | null;
customActivities?: Record<string, CustomActivity>;
}
const techQuotes = {
de: [
{ content: "Informatik hat nicht mehr mit Computern zu tun als Astronomie mit Teleskopen.", author: "Edsger W. Dijkstra" },
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" },
{ content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" },
{ content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" },
{ content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" },
{ content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" },
{ content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" },
{ content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" },
{ content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" },
],
en: [
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" },
{ content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" },
{ content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" },
{ content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" },
{ content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" },
{ content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" },
{ content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" },
{ content: "The only way to go fast is to go well.", author: "Robert C. Martin" },
]
};
function getSafeGamingText(details: string | number | undefined, state: string | number | undefined, fallback: string): string {
if (typeof details === 'string' && details.trim().length > 0) return details;
if (typeof state === 'string' && state.trim().length > 0) return state;
return fallback;
}
export default function ActivityFeed({
onActivityChange,
locale = 'en'
}: {
onActivityChange?: (active: boolean) => void;
locale?: string;
}) {
const [data, setData] = useState<StatusData | null>(null);
const [hasActivity, setHasActivity] = useState(false);
const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length));
const [loading, setLoading] = useState(true);
const t = useTranslations("home.about.activity");
const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch("/api/n8n/status");
if (!res.ok) throw new Error();
const json = await res.json();
const activityData = Array.isArray(json) ? json[0] : json;
setData(activityData);
const isActive = Boolean(
activityData.coding?.isActive ||
activityData.gaming?.isPlaying ||
activityData.music?.isPlaying ||
Object.values(activityData.customActivities || {}).some((act) => Boolean((act as { enabled?: boolean })?.enabled))
);
setHasActivity(isActive);
onActivityChange?.(isActive);
} catch {
setHasActivity(false);
onActivityChange?.(false);
} finally {
setLoading(false);
}
};
fetchData();
const statusInterval = setInterval(fetchData, 30000);
// Pick a random quote every 10 seconds (never the same one twice in a row)
const quoteInterval = setInterval(() => {
setQuoteIndex((prev) => {
let next;
do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1);
return next;
});
}, 10000);
return () => {
clearInterval(statusInterval);
clearInterval(quoteInterval);
};
}, [onActivityChange]);
if (loading) {
return <div className="animate-pulse space-y-4">
<div className="h-24 bg-stone-100 dark:bg-stone-800 rounded-2xl" />
</div>;
}
if (!hasActivity) {
return (
<div className="h-full flex flex-col justify-center space-y-6">
<div className="w-10 h-10 rounded-full bg-liquid-mint/10 flex items-center justify-center">
<QuoteIcon size={18} className="text-emerald-600 dark:text-liquid-mint" />
</div>
<div className="min-h-[80px] sm:min-h-[120px] relative">
<AnimatePresence mode="wait">
<motion.div
key={quoteIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.5 }}
className="space-y-4"
>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-300 italic">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
{allQuotes[quoteIndex].author}
</p>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 dark:text-stone-600 pt-4 border-t border-stone-100 dark:border-stone-800">
<span className="w-1.5 h-1.5 rounded-full bg-stone-200 dark:bg-stone-700 animate-pulse" />
{t("idleStatus")}
</div>
</div>
);
}
return (
<div className="space-y-4">
{data?.coding?.isActive && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-emerald-500/5 dark:bg-emerald-500/10 border border-emerald-500/20 rounded-2xl p-5">
<div className="flex items-center gap-3 mb-2">
<Zap size={14} className="text-emerald-600 dark:text-emerald-400 animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">{t("codingNow")}</span>
</div>
<p className="font-bold text-stone-900 dark:text-white text-lg truncate">{data.coding.project}</p>
<p className="text-xs text-stone-500 dark:text-white/50 truncate">{data.coding.file}</p>
</motion.div>
)}
{data?.gaming?.isPlaying && (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="bg-indigo-500/5 dark:bg-indigo-500/10 border border-indigo-500/20 rounded-2xl p-5">
<div className="flex items-center gap-3 mb-3">
<Gamepad2 size={14} className="text-indigo-600 dark:text-indigo-400" />
<span className="text-[10px] font-black uppercase tracking-widest text-indigo-600 dark:text-indigo-400">{t("gaming")}</span>
</div>
<div className="flex gap-4">
{data.gaming.image && (
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative">
<Image src={data.gaming.image} alt={data.gaming.name} fill className="object-cover" sizes="48px" unoptimized />
</div>
)}
<div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-stone-900 dark:text-white text-base truncate">{data.gaming.name}</p>
<p className="text-xs text-stone-500 dark:text-white/50 truncate">
{getSafeGamingText(data.gaming.details, data.gaming.state, t("inGame"))}
</p>
</div>
</div>
</motion.div>
)}
{data?.music?.isPlaying && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#1DB954]/5 dark:bg-[#1DB954]/10 border border-[#1DB954]/20 rounded-2xl p-5 relative overflow-hidden group"
>
<div className="flex items-center justify-between mb-3 relative z-10">
<div className="flex items-center gap-2">
<Disc3 size={14} className="text-[#1DB954] animate-spin-slow" />
<span className="text-[10px] font-black uppercase tracking-widest text-[#1DB954]">{t("listening")}</span>
</div>
{/* Simple Animated Music Bars */}
<div className="flex items-end gap-[2px] h-3">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
animate={{ height: ["20%", "100%", "20%"] }}
transition={{
duration: 0.8,
repeat: Infinity,
delay: i * 0.2,
ease: "easeInOut"
}}
className="w-[2px] bg-[#1DB954] rounded-full"
/>
))}
</div>
</div>
<div className="flex gap-4 relative z-10">
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-500">
<Image
src={data.music.albumArt}
alt="Album Art"
fill
className="object-cover transition-transform duration-700 group-hover:scale-110"
sizes="64px"
/>
</div>
<div className="min-w-0 flex flex-col justify-center">
<p className="font-bold text-[#1DB954] dark:text-[#1DB954] text-base truncate leading-tight mb-1">{data.music.track}</p>
<p className="text-sm text-stone-600 dark:text-white/60 truncate font-medium">{data.music.artist}</p>
</div>
</div>
{/* Subtle Spotify branding gradient */}
<div className="absolute top-0 right-0 w-32 h-32 bg-[#1DB954]/5 blur-[60px] rounded-full -mr-16 -mt-16 pointer-events-none" />
</motion.div>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import React, { useEffect, useState } from "react";
import BackgroundBlobs from "@/components/BackgroundBlobs";
export default function BackgroundBlobsClient() {
// Avoid SSR/webpack bailout issues from `next/dynamic({ ssr:false })`
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return <BackgroundBlobs />;
}

View File

@@ -0,0 +1,114 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Send, Loader2 } from "lucide-react";
interface Message {
id: string;
text: string;
sender: "user" | "bot";
timestamp: Date;
}
interface StoredMessage {
id: string;
text: string;
sender: "user" | "bot";
timestamp: string;
}
export default function BentoChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const storedId = localStorage.getItem("chatSessionId");
if (storedId) setConversationId(storedId);
else {
const newId = crypto.randomUUID();
localStorage.setItem("chatSessionId", newId);
setConversationId(newId);
}
const storedMsgs = localStorage.getItem("chatMessages");
if (storedMsgs) {
setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) })));
} else {
setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]);
}
} catch {}
}, []);
useEffect(() => {
if (messages.length > 0) {
localStorage.setItem("chatMessages", JSON.stringify(messages));
}
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages]);
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
const userMsg: Message = { id: Date.now().toString(), text: inputValue.trim(), sender: "user", timestamp: new Date() };
setMessages(prev => [...prev, userMsg]);
setInputValue("");
setIsLoading(true);
try {
const res = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: userMsg.text, conversationId, history: messages.slice(-5).map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })) }),
});
const data = await res.json();
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: data.reply || "Error", sender: "bot", timestamp: new Date() }]);
} catch {
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: "Connection error.", sender: "bot", timestamp: new Date() }]);
} finally {
setIsLoading(true);
setTimeout(() => setIsLoading(false), 500); // Small delay for feel
}
};
return (
<div className="flex flex-col h-full min-h-[300px]">
<div
ref={containerRef}
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
>
{messages.map((m) => (
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
{m.text}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
</div>
)}
</div>
<div className="relative">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Ask me..."
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
/>
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
<Send size={18} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,491 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
MessageCircle,
X,
Send,
Loader2,
Sparkles,
Trash2,
} from "lucide-react";
interface Message {
id: string;
text: string;
sender: "user" | "bot";
timestamp: Date;
isTyping?: boolean;
}
export default function ChatWidget() {
// Prevent hydration mismatch by only rendering after mount
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [conversationId, setConversationId] = useState<string>("default");
useEffect(() => {
setMounted(true);
// Generate or retrieve conversation ID only on client
try {
const stored = localStorage.getItem("chatSessionId");
if (stored) {
setConversationId(stored);
return;
}
// Generate UUID with fallback for browsers without crypto.randomUUID
let newId: string;
if (typeof crypto !== "undefined" && crypto.randomUUID) {
newId = crypto.randomUUID();
} else {
// Fallback UUID generation
newId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
localStorage.setItem("chatSessionId", newId);
setConversationId(newId);
} catch (error) {
// localStorage might be disabled or full
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to access localStorage for chat session:', error);
}
setConversationId(`session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
}
}, []);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Focus input when chat opens
useEffect(() => {
if (isOpen) {
inputRef.current?.focus();
}
}, [isOpen]);
// Helper function to decode HTML entities
const decodeHtmlEntities = (text: string): string => {
if (!text || typeof text !== "string") return text;
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
};
// Load messages from localStorage
useEffect(() => {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem("chatMessages");
if (stored) {
try {
const parsed = JSON.parse(stored);
setMessages(
parsed.map((m: Message) => ({
...m,
text: decodeHtmlEntities(m.text), // Decode HTML entities when loading
timestamp: new Date(m.timestamp),
})),
);
} catch (e) {
if (process.env.NODE_ENV === 'development') {
console.error("Failed to parse chat history", e);
}
// Clear corrupted data
try {
localStorage.removeItem("chatMessages");
} catch {
// Ignore cleanup errors
}
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
} else {
// Add welcome message
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
} catch (error) {
// localStorage might be disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to load chat history from localStorage:", error);
}
// Add welcome message anyway
setMessages([
{
id: "welcome",
text: "Hi! I'm Dennis's AI assistant. Ask me anything about his skills, projects, or experience! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
}
}
}, []);
// Save messages to localStorage
useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) {
try {
localStorage.setItem("chatMessages", JSON.stringify(messages));
} catch (error) {
// localStorage might be full or disabled
if (process.env.NODE_ENV === 'development') {
console.warn("Failed to save chat messages to localStorage:", error);
}
}
}
}, [messages]);
const handleSend = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage: Message = {
id: Date.now().toString(),
text: inputValue.trim(),
sender: "user",
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue("");
setIsLoading(true);
// Get last 10 messages for context
const history = messages.slice(-10).map((m) => ({
role: m.sender === "user" ? "user" : "assistant",
content: m.text,
}));
try {
const response = await fetch("/api/n8n/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: userMessage.text,
conversationId,
history,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error");
console.error("Chat API error:", {
status: response.status,
statusText: response.statusText,
error: errorText,
});
throw new Error(
`Failed to get response: ${response.status} - ${errorText.substring(0, 100)}`,
);
}
const data = await response.json();
// Log response for debugging (only in development)
if (process.env.NODE_ENV === "development") {
console.log("Chat API response:", data);
}
// Decode HTML entities in the reply
let replyText =
data.reply || "Sorry, I couldn't process that. Please try again.";
// Decode HTML entities client-side (double safety)
replyText = decodeHtmlEntities(replyText);
const botMessage: Message = {
id: (Date.now() + 1).toString(),
text: replyText,
sender: "bot",
timestamp: new Date(),
};
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
console.error("Chat error:", error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
text: "Sorry, I'm having trouble connecting right now. Please try again later or use the contact form below.",
sender: "bot",
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const clearChat = () => {
// Reset session ID
const newId = crypto.randomUUID();
setConversationId(newId);
if (typeof window !== "undefined") {
localStorage.setItem("chatSessionId", newId);
localStorage.removeItem("chatMessages");
}
setMessages([
{
id: "welcome",
text: "Conversation restarted! Ask me anything about Dennis! 🚀",
sender: "bot",
timestamp: new Date(),
},
]);
};
// Don't render until mounted to prevent hydration mismatch
if (!mounted) {
return null;
}
return (
<>
{/* Chat Button */}
<AnimatePresence>
{!isOpen && (
<motion.div
role="button"
tabIndex={0}
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
onClick={() => setIsOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setIsOpen(true);
}
}}
className="fixed bottom-4 left-4 md:bottom-6 md:left-6 z-30 bg-white/80 backdrop-blur-xl text-stone-900 p-3.5 rounded-full shadow-[0_10px_26px_rgba(41,37,36,0.16)] hover:bg-white hover:scale-105 transition-all duration-300 group cursor-pointer border border-white/60 ring-1 ring-white/30"
aria-label="Open chat"
>
<MessageCircle size={24} />
<span className="absolute top-0 right-0 w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-sm border-2 border-white" />
{/* Tooltip */}
<span className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-1.5 bg-stone-900/90 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-[100] shadow-xl backdrop-blur-sm">
Chat with AI
</span>
</motion.div>
)}
</AnimatePresence>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
data-chat-widget
initial={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: 20, scale: 0.95, filter: "blur(10px)" }}
transition={{ type: "spring", damping: 30, stiffness: 400 }}
className="fixed bottom-20 left-4 right-4 md:bottom-24 md:left-6 md:right-auto z-30 md:w-[380px] h-[60vh] md:h-[550px] max-h-[600px] bg-white/80 backdrop-blur-xl saturate-100 rounded-2xl shadow-[0_12px_40px_rgba(41,37,36,0.16)] flex flex-col overflow-hidden border border-white/60 ring-1 ring-white/30"
>
{/* Header */}
<div className="bg-white/70 text-stone-900 p-4 flex items-center justify-between border-b border-white/50">
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-liquid-mint/50 via-liquid-lavender/40 to-liquid-rose/40 flex items-center justify-center ring-1 ring-white/50 shadow-sm">
<Sparkles size={18} className="text-stone-800" />
</div>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-500 rounded-full border-2 border-white shadow-sm" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-bold text-sm truncate text-stone-900 tracking-tight">
Assistant
</h3>
<p className="text-[11px] font-medium text-stone-500 truncate">
Powered by AI
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={clearChat}
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-red-500"
title="Clear conversation"
>
<Trash2 size={16} />
</button>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-stone-200/40 rounded-full transition-colors text-stone-500 hover:text-stone-900"
aria-label="Close chat"
>
<X size={20} />
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto scrollbar-hide p-4 space-y-4 bg-transparent">
{messages.map((message) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 shadow-sm ${
message.sender === "user"
? "bg-stone-900 text-white"
: "bg-white/70 text-stone-900 border border-white/60"
}`}
>
<p className={`text-sm whitespace-pre-wrap break-words leading-relaxed ${
message.sender === "user" ? "text-white/90 font-normal" : "text-stone-900 font-medium"
}`}>
{message.text}
</p>
<p
className={`text-[10px] mt-1.5 ${
message.sender === "user"
? "text-stone-400"
: "text-stone-500"
}`}
>
{message.timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</motion.div>
))}
{/* Typing Indicator */}
{isLoading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="bg-[#f3f1e7] border border-[#e7e5e4] rounded-2xl px-4 py-3 shadow-sm">
<div className="flex gap-1.5">
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0,
}}
/>
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.1,
}}
/>
<motion.div
className="w-1.5 h-1.5 bg-stone-500 rounded-full"
animate={{ y: [0, -6, 0] }}
transition={{
duration: 0.6,
repeat: Infinity,
delay: 0.2,
}}
/>
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-[#fdfcf8] border-t border-[#e7e5e4]">
<div className="flex gap-2">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Ask anything..."
disabled={isLoading}
className="flex-1 px-4 py-3 text-sm bg-[#f5f5f4] text-[#292524] rounded-xl border border-[#e7e5e4] focus:outline-none focus:ring-2 focus:ring-[#e7e5e4] focus:border-[#a8a29e] focus:bg-[#fdfcf8] disabled:opacity-50 disabled:cursor-not-allowed placeholder:text-[#78716c] transition-all shadow-inner"
/>
<button
onClick={handleSend}
disabled={!inputValue.trim() || isLoading}
className="p-3 bg-[#292524] text-[#fdfcf8] rounded-xl hover:bg-[#44403c] hover:shadow-lg hover:scale-105 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 shadow-md flex items-center justify-center aspect-square"
aria-label="Send message"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Send size={18} />
)}
</button>
</div>
{/* Quick Actions */}
<div className="flex gap-2 mt-3 overflow-x-auto pb-1 scrollbar-hide mask-fade-right">
{[
"Skills 🛠️",
"Projects 🚀",
"Contact 📧",
].map((suggestion, index) => (
<button
key={index}
onClick={() => {
setInputValue(suggestion.replace(/ .*/, '')); // Strip emoji for search if needed, or keep
inputRef.current?.focus();
}}
disabled={isLoading}
className="px-3 py-1.5 text-xs font-medium bg-[#f5f5f4] text-[#57534e] rounded-lg hover:bg-[#e7e5e4] hover:text-[#292524] border border-[#e7e5e4] transition-all whitespace-nowrap disabled:opacity-50 flex-shrink-0 shadow-sm"
>
{suggestion}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import React, { useEffect, useState } from "react";
export default function ClientOnly({ children }: { children: React.ReactNode }) {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,113 @@
"use client";
import React, { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import dynamic from "next/dynamic";
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"),
{ ssr: false, loading: () => null }
);
export default function ClientProviders({
children,
}: {
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 (
<ErrorBoundary>
<ErrorBoundary>
<ConsentProvider>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<GatedProviders mounted={mounted} is404Page={is404Page}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
</GatedProviders>
</ThemeProvider>
</ConsentProvider>
</ErrorBoundary>
</ErrorBoundary>
);
}
function GatedProviders({
children,
mounted,
}: {
children: React.ReactNode;
mounted: boolean;
is404Page: boolean;
}) {
return (
<ErrorBoundary>
<ToastProvider>
{mounted && <BackgroundBlobs />}
{mounted && <ShaderGradientBackground />}
<div className="relative z-10">{children}</div>
</ToastProvider>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
/**
* Transitional Wrapper für bestehende Components
* Nutzt direkt JSON Messages statt komplexe Translation-Loader
*/
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 type {
HeroTranslations,
AboutTranslations,
ProjectsTranslations,
ContactTranslations,
FooterTranslations,
} from '@/types/translations';
import enMessages from '@/messages/en.json';
import deMessages from '@/messages/de.json';
const messageMap = { en: enMessages, de: deMessages };
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 (
<NextIntlClientProvider locale={locale} messages={messages}>
<Hero />
</NextIntlClientProvider>
);
}
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
about: baseMessages.home.about
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<About />
</NextIntlClientProvider>
);
}
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
projects: baseMessages.home.projects
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Projects />
</NextIntlClientProvider>
);
}
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
contact: baseMessages.home.contact
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Contact />
</NextIntlClientProvider>
);
}
export function FooterClient({ locale }: { locale: string; translations: FooterTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
footer: baseMessages.footer
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Footer />
</NextIntlClientProvider>
);
}

Some files were not shown because too many files have changed in this diff Show More