Compare commits

..

90 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
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
180 changed files with 18744 additions and 11832 deletions

View File

@@ -5,7 +5,7 @@ on:
branches: [ dev, main, production ]
env:
NODE_VERSION: '20'
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
CONTAINER_NAME: portfolio-app

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

@@ -5,7 +5,7 @@ on:
branches: [ dev ]
env:
NODE_VERSION: '20'
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: dev
@@ -68,12 +68,17 @@ jobs:
# Remove old images to force re-pull with correct architecture
echo "🔄 Removing old images to force re-pull..."
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true
docker rmi postgres:16-alpine redis:7-alpine 2>/dev/null || true
# Ensure networks exist before compose starts (network is external)
echo "🌐 Ensuring networks exist..."
docker network create portfolio_dev 2>/dev/null || true
docker network create proxy 2>/dev/null || true
# Pull images with correct architecture (Docker will auto-detect)
echo "📥 Pulling images for current architecture..."
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
@@ -192,25 +197,6 @@ jobs:
fi
fi
# Ensure networks exist
echo "🌐 Checking for networks..."
if ! docker network inspect proxy >/dev/null 2>&1; then
echo "⚠️ Proxy network not found, creating it..."
docker network create proxy 2>/dev/null || echo "Network might already exist or creation failed"
else
echo "✅ Proxy network exists"
fi
if ! docker network inspect portfolio_dev >/dev/null 2>&1; then
echo "⚠️ Portfolio dev network not found, creating it..."
docker network create portfolio_dev 2>/dev/null || echo "Network might already exist or creation failed"
else
echo "✅ Portfolio dev network exists"
fi
# Connect proxy network to portfolio_dev network if needed
# (This allows the app to access both proxy and DB/Redis)
# Start new container with updated image
echo "🆕 Starting new dev container..."
docker run -d \

View File

@@ -5,7 +5,7 @@ on:
branches: [ production ]
env:
NODE_VERSION: '20'
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: production
@@ -70,11 +70,16 @@ jobs:
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 --no-deps --build portfolio
docker compose -f $COMPOSE_FILE up -d portfolio
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."

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,334 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches: [main, dev, production]
pull_request:
branches: [main, dev, production]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Test Job (parallel)
test:
name: Run Tests
runs-on: self-hosted # Use your own server for speed!
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Create test environment file
run: |
cat > .env <<EOF
NODE_ENV=test
NEXT_PUBLIC_BASE_URL=http://localhost:3000
MY_EMAIL=test@example.com
MY_INFO_EMAIL=test@example.com
MY_PASSWORD=test
MY_INFO_PASSWORD=test
NEXT_PUBLIC_UMAMI_URL=https://analytics.dk0.dev
NEXT_PUBLIC_UMAMI_WEBSITE_ID=b3665829-927a-4ada-b9bb-fcf24171061e
ADMIN_BASIC_AUTH=admin:test
LOG_LEVEL=info
EOF
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
- name: Build application
run: npm run build
# Security scan (parallel)
security:
name: Security Scan
runs-on: self-hosted # Use your own server for speed!
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
skip-version-check: true
scanners: 'vuln,secret,config'
- name: Upload Trivy scan results as artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: trivy-security-report
path: trivy-results.sarif
retention-days: 30
# Build and push Docker image
build:
name: Build and Push Docker Image
runs-on: self-hosted # Use your own server for speed!
needs: [test, security] # Wait for parallel jobs to complete
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/production')
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=staging,enable={{is_default_branch==false && branch=='dev'}}
type=raw,value=staging,enable={{is_default_branch==false && branch=='main'}}
- name: Create production environment file
run: |
cat > .env <<EOF
NODE_ENV=production
NEXT_PUBLIC_BASE_URL=${{ vars.NEXT_PUBLIC_BASE_URL }}
MY_EMAIL=${{ vars.MY_EMAIL }}
MY_INFO_EMAIL=${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD=${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD=${{ secrets.MY_INFO_PASSWORD }}
NEXT_PUBLIC_UMAMI_URL=${{ vars.NEXT_PUBLIC_UMAMI_URL }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
ADMIN_BASIC_AUTH=${{ secrets.ADMIN_BASIC_AUTH }}
LOG_LEVEL=info
EOF
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64 # Only AMD64 for speed
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Optimize for speed
build-args: |
BUILDKIT_INLINE_CACHE=1
# Deploy to staging (dev/main branches)
deploy-staging:
name: Deploy to Staging
runs-on: self-hosted
needs: build
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
environment: staging
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy staging to server
run: |
# Set deployment variables
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging"
export CONTAINER_NAME="portfolio-app-staging"
export COMPOSE_FILE="docker-compose.staging.yml"
# Set environment variables for docker-compose
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL_STAGING || vars.NEXT_PUBLIC_BASE_URL }}"
export MY_EMAIL="${{ vars.MY_EMAIL }}"
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
# Pull latest staging image
docker pull $IMAGE_NAME || docker pull "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main" || true
# Stop and remove old staging container (if exists)
docker compose -f $COMPOSE_FILE down || true
# Start new staging container
docker compose -f $COMPOSE_FILE up -d --force-recreate
# Wait for health check
echo "Waiting for staging application to be healthy..."
for i in {1..30}; do
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
echo "✅ Staging deployment successful!"
break
fi
sleep 2
done
# Verify deployment
if curl -f http://localhost:3002/api/health; then
echo "✅ Staging deployment verified!"
else
echo "⚠️ Staging health check failed, but container is running"
docker compose -f $COMPOSE_FILE logs --tail=50
fi
# Deploy to production
deploy:
name: Deploy to Production
runs-on: self-hosted
needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/production'
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Deploy to production (zero-downtime)
run: |
# Set deployment variables
export IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production"
export CONTAINER_NAME="portfolio-app"
export COMPOSE_FILE="docker-compose.production.yml"
export BACKUP_CONTAINER="portfolio-app-backup"
# Set environment variables for docker-compose
export NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}"
export MY_EMAIL="${{ vars.MY_EMAIL }}"
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
# Pull latest production image
echo "📦 Pulling latest production image..."
docker pull $IMAGE_NAME
# Check if production container is running
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "🔄 Production container is running - performing zero-downtime deployment..."
# Start new container with different name first (blue-green)
echo "🚀 Starting new container (green)..."
docker run -d \
--name ${BACKUP_CONTAINER} \
--network portfolio_net \
-p 3002:3000 \
-e NODE_ENV=production \
-e DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public \
-e REDIS_URL=redis://redis:6379 \
-e NEXT_PUBLIC_BASE_URL="${{ vars.NEXT_PUBLIC_BASE_URL }}" \
-e MY_EMAIL="${{ vars.MY_EMAIL }}" \
-e MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}" \
-e MY_PASSWORD="${{ secrets.MY_PASSWORD }}" \
-e MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}" \
-e ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}" \
$IMAGE_NAME || true
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
for i in {1..30}; do
if curl -f http://localhost:3002/api/health > /dev/null 2>&1; then
echo "✅ New container is healthy!"
break
fi
sleep 2
done
# Stop old container
echo "🛑 Stopping old container..."
docker stop ${CONTAINER_NAME} || true
# Remove old container
docker rm ${CONTAINER_NAME} || true
# Rename new container to production name
docker rename ${BACKUP_CONTAINER} ${CONTAINER_NAME}
# Update port mapping (requires container restart, but it's already healthy)
docker stop ${CONTAINER_NAME}
docker rm ${CONTAINER_NAME}
# Start with correct port using docker-compose
docker compose -f $COMPOSE_FILE up -d --force-recreate
else
echo "🆕 No existing container - starting fresh deployment..."
docker compose -f $COMPOSE_FILE up -d --force-recreate
fi
# Wait for health check
echo "⏳ Waiting for production application to be healthy..."
for i in {1..30}; do
if curl -f http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Production deployment successful!"
break
fi
sleep 2
done
# Verify deployment
if curl -f http://localhost:3000/api/health; then
echo "✅ Production deployment verified!"
else
echo "❌ Production deployment failed!"
docker compose -f $COMPOSE_FILE logs --tail=100
exit 1
fi
# Cleanup backup container if it exists
docker rm -f ${BACKUP_CONTAINER} 2>/dev/null || true
- name: Cleanup old images
run: |
# Remove unused images older than 7 days
docker image prune -f --filter "until=168h"
# Remove unused containers
docker container prune -f

8
.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,6 +37,10 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# Sentry
.sentryclirc
sentry.properties
# vercel
.vercel

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

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,5 +1,5 @@
# 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
@@ -67,10 +67,6 @@ 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)
@@ -79,6 +75,10 @@ RUN chown nextjs:nodejs .next
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

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,6 +1,7 @@
# Quick links
- **Ops / setup / deployment / testing**: `docs/OPERATIONS.md`
- **Locale System & Translations**: `docs/LOCALE_SYSTEM.md`
# Dennis Konkol Portfolio - Modern Dark Theme

View File

@@ -1,324 +0,0 @@
# 🚀 Safe Push to Main Branch Guide
**IMPORTANT**: This guide ensures you don't break production when merging to main.
## ⚠️ Pre-Flight Checklist
Before even thinking about pushing to main, verify ALL of these:
### 1. Code Quality ✅
```bash
# Run all checks
npm run build # Must pass with 0 errors
npm run lint # Must pass with 0 errors
npx tsc --noEmit # TypeScript must be clean
npx prisma format # Database schema must be valid
```
### 1b. Automated Testing ✅
```bash
# Run comprehensive test suite (RECOMMENDED)
npm run test:all # Runs all tests including E2E
# Or run individually:
npm run test # Unit tests
npm run test:critical # Critical path E2E tests
npm run test:hydration # Hydration tests
npm run test:email # Email API tests
```
### 2. Testing ✅
```bash
# Automated testing (RECOMMENDED)
npm run test:all # Runs all automated tests
# Manual testing (if needed)
npm run dev
# Test these critical paths:
# - Home page loads
# - Projects page works
# - Admin dashboard accessible
# - API endpoints respond
# - No console errors
# - No hydration errors
```
### 3. Database Changes ✅
```bash
# If you changed the database schema:
# 1. Create migration
npx prisma migrate dev --name your_migration_name
# 2. Test migration on a copy of production data
# 3. Document migration steps
# 4. Create rollback plan
```
### 4. Environment Variables ✅
- [ ] All new env vars documented in `env.example`
- [ ] No secrets committed to git
- [ ] Production env vars are set on server
- [ ] Optional features have fallbacks
### 5. Breaking Changes ✅
- [ ] Documented in CHANGELOG
- [ ] Backward compatible OR migration plan exists
- [ ] Team notified of changes
---
## 📋 Step-by-Step Push Process
### Step 1: Ensure You're on Dev Branch
```bash
git checkout dev
git pull origin dev # Get latest changes
```
### Step 2: Final Verification
```bash
# Clean build
rm -rf .next node_modules/.cache
npm install
npm run build
# Should complete without errors
```
### Step 3: Review Your Changes
```bash
# See what you're about to push
git log origin/main..dev --oneline
git diff origin/main..dev
# Review carefully:
# - No accidental secrets
# - No debug code
# - No temporary files
# - All changes are intentional
```
### Step 4: Create a Backup Branch (Safety Net)
```bash
# Create backup before merging
git checkout -b backup-before-main-merge-$(date +%Y%m%d)
git push origin backup-before-main-merge-$(date +%Y%m%d)
git checkout dev
```
### Step 5: Merge Dev into Main (Local)
```bash
# Switch to main
git checkout main
git pull origin main # Get latest main
# Merge dev into main
git merge dev --no-ff -m "Merge dev into main: [describe changes]"
# If conflicts occur:
# 1. Resolve conflicts carefully
# 2. Test after resolving
# 3. Don't force push if unsure
```
### Step 6: Test the Merged Code
```bash
# Build and test the merged code
npm run build
npm run dev
# Test critical paths again
# - Home page
# - Projects
# - Admin
# - APIs
```
### Step 7: Push to Main (If Everything Looks Good)
```bash
# Push to remote main
git push origin main
# If you need to force push (DANGEROUS - only if necessary):
# git push origin main --force-with-lease
```
### Step 8: Monitor Deployment
```bash
# Watch your deployment logs
# Check for errors
# Verify health endpoints
# Test production site
```
---
## 🛡️ Safety Strategies
### Strategy 1: Feature Flags
If you're adding new features, use feature flags:
```typescript
// In your code
if (process.env.ENABLE_NEW_FEATURE === 'true') {
// New feature code
}
```
### Strategy 2: Gradual Rollout
- Deploy to staging first
- Test thoroughly
- Then deploy to production
- Monitor closely
### Strategy 3: Database Migrations
```bash
# Always test migrations first
# 1. Backup production database
# 2. Test migration on copy
# 3. Create rollback script
# 4. Run migration during low-traffic period
```
### Strategy 4: Rollback Plan
Always have a rollback plan:
```bash
# If something breaks:
git revert HEAD
git push origin main
# Or rollback to previous commit:
git reset --hard <previous-commit-hash>
git push origin main --force-with-lease
```
---
## 🚨 Red Flags - DON'T PUSH IF:
- ❌ Build fails
- ❌ Tests fail
- ❌ Linter errors
- ❌ TypeScript errors
- ❌ Database migration not tested
- ❌ Breaking changes not documented
- ❌ Secrets in code
- ❌ Debug code left in
- ❌ Console.logs everywhere
- ❌ Untested features
- ❌ No rollback plan
---
## ✅ Green Lights - SAFE TO PUSH IF:
- ✅ All checks pass
- ✅ Tested locally
- ✅ Database migrations tested
- ✅ No breaking changes (or documented)
- ✅ Documentation updated
- ✅ Team notified
- ✅ Rollback plan exists
- ✅ Feature flags for new features
- ✅ Environment variables documented
---
## 📝 Pre-Push Checklist Template
Copy this and check each item:
```
[ ] npm run build passes
[ ] npm run lint passes
[ ] npx tsc --noEmit passes
[ ] npx prisma format passes
[ ] npm run test:all passes (automated tests)
[ ] OR manual testing:
[ ] Dev server starts without errors
[ ] Home page loads correctly
[ ] Projects page works
[ ] Admin dashboard accessible
[ ] API endpoints respond
[ ] No console errors
[ ] No hydration errors
[ ] Database migrations tested (if any)
[ ] Environment variables documented
[ ] No secrets in code
[ ] Breaking changes documented
[ ] CHANGELOG updated
[ ] Team notified (if needed)
[ ] Rollback plan exists
[ ] Backup branch created
[ ] Changes reviewed
```
---
## 🔄 Alternative: Pull Request Workflow
If you want extra safety, use PR workflow:
```bash
# 1. Push dev branch
git push origin dev
# 2. Create Pull Request on Git platform
# - Review changes
# - Get approval
# - Run CI/CD checks
# 3. Merge PR to main (platform handles it)
```
---
## 🆘 Emergency Rollback
If production breaks after push:
### Quick Rollback
```bash
# 1. Revert the merge commit
git revert -m 1 <merge-commit-hash>
git push origin main
# 2. Or reset to previous state
git reset --hard <previous-commit>
git push origin main --force-with-lease
```
### Database Rollback
```bash
# If you ran migrations, roll them back:
npx prisma migrate resolve --rolled-back <migration-name>
# Or restore from backup
```
---
## 📞 Need Help?
If unsure:
1. **Don't push** - better safe than sorry
2. Test more thoroughly
3. Ask for code review
4. Use staging environment first
5. Create a PR for review
---
## 🎯 Best Practices
1. **Always test locally first**
2. **Use feature flags for new features**
3. **Test database migrations on copies**
4. **Document everything**
5. **Have a rollback plan**
6. **Monitor after deployment**
7. **Deploy during low-traffic periods**
8. **Keep main branch stable**
---
**Remember**: It's better to delay a push than to break production! 🛡️

View File

@@ -1,120 +0,0 @@
# 🔒 Security Improvements
## Implemented Security Features
### 1. n8n API Endpoint Protection
All n8n endpoints are now protected with:
- **Authentication**: Admin authentication required for sensitive endpoints (`/api/n8n/generate-image`)
- **Rate Limiting**:
- `/api/n8n/generate-image`: 10 requests/minute
- `/api/n8n/chat`: 20 requests/minute
- `/api/n8n/status`: 30 requests/minute
### 2. Email Obfuscation
Email addresses can now be obfuscated to prevent automated scraping:
```typescript
import { createObfuscatedMailto } from '@/lib/email-obfuscate';
import { ObfuscatedEmail } from '@/components/ObfuscatedEmail';
// React component
<ObfuscatedEmail email="contact@dk0.dev">Contact Me</ObfuscatedEmail>
// HTML string
const mailtoLink = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
```
**How it works:**
- Emails are base64 encoded in the HTML
- JavaScript decodes them on click
- Prevents simple regex-based email scrapers
- Still functional for real users
### 3. URL Obfuscation
Sensitive URLs can be obfuscated:
```typescript
import { createObfuscatedLink } from '@/lib/email-obfuscate';
const link = createObfuscatedLink('https://sensitive-url.com', 'Click Here');
```
### 4. Rate Limiting
All API endpoints have rate limiting:
- Prevents brute force attacks
- Protects against DDoS
- Configurable per endpoint
## Code Obfuscation
**Note**: Full code obfuscation for Next.js is **not recommended** because:
1. **Next.js already minifies code** in production builds
2. **Obfuscation breaks source maps** (harder to debug)
3. **Performance impact** (slower execution)
4. **Not effective** - determined attackers can still reverse engineer
5. **Maintenance burden** - harder to debug issues
**Better alternatives:**
- ✅ Minification (already enabled in Next.js)
- ✅ Environment variables for secrets
- ✅ Server-side rendering (code not exposed)
- ✅ API authentication
- ✅ Rate limiting
- ✅ Security headers
## Best Practices
### For Email Protection:
1. Use obfuscated emails in public HTML
2. Use contact forms instead of direct mailto links
3. Monitor for spam patterns
### For API Protection:
1. Always require authentication for sensitive endpoints
2. Use rate limiting
3. Log suspicious activity
4. Use HTTPS only
5. Validate all inputs
### For Webhook Protection:
1. Use secret tokens (`N8N_SECRET_TOKEN`)
2. Verify webhook signatures
3. Rate limit webhook endpoints
4. Monitor webhook usage
## Implementation Status
- ✅ n8n endpoints protected with auth + rate limiting
- ✅ Email obfuscation utility created
- ✅ URL obfuscation utility created
- ✅ Rate limiting on all n8n endpoints
- ⚠️ Email obfuscation not yet applied to pages (manual step)
- ⚠️ Code obfuscation not implemented (not recommended)
## Next Steps
To apply email obfuscation to your pages:
1. Import the utility:
```typescript
import { ObfuscatedEmail } from '@/lib/email-obfuscate';
```
2. Replace email links:
```tsx
// Before
<a href="mailto:contact@dk0.dev">Contact</a>
// After
<ObfuscatedEmail email="contact@dk0.dev">Contact</ObfuscatedEmail>
```
3. For static HTML, use the string function:
```typescript
const html = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
```

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.

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.

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

View File

@@ -1,8 +1,31 @@
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,
@@ -11,11 +34,17 @@ export default async function LocaleLayout({
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 import(`../../messages/${locale}.json`)).default;
const messages = await loadEnhancedMessages(locale);
return (
<NextIntlClientProvider locale={locale} messages={messages}>

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import HomePage from "../_ui/HomePage";
import HomePageServer from "../_ui/HomePageServer";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export async function generateMetadata({
@@ -17,7 +17,12 @@ export async function generateMetadata({
};
}
export default function Page() {
return <HomePage />;
export default async function Page({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return <HomePageServer locale={locale} />;
}

View File

@@ -3,6 +3,8 @@ 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;
@@ -12,6 +14,20 @@ export async function generateMetadata({
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: {
@@ -28,26 +44,76 @@ export default async function ProjectPage({
}) {
const { locale, slug } = await params;
const project = await prisma.project.findFirst({
// Try PostgreSQL first
const dbProject = await prisma.project.findFirst({
where: { slug, published: true },
include: {
translations: {
where: { locale },
select: { title: true, description: true },
select: { title: true, description: true, content: true, locale: true },
},
},
});
if (!project) return notFound();
let projectData: ProjectDetailData | null = null;
const tr = project.translations?.[0];
const { translations: _translations, ...rest } = project;
const localized = {
...rest,
title: tr?.title ?? project.title,
description: tr?.description ?? project.description,
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 <ProjectDetailClient project={localized} locale={locale} />;
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<ProjectDetailClient project={projectData} locale={locale} />
</>
);
}

View File

@@ -1,7 +1,8 @@
import { prisma } from "@/lib/prisma";
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
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;
@@ -27,27 +28,67 @@ export default async function ProjectsPage({
}) {
const { locale } = await params;
const projects = await prisma.project.findMany({
// Fetch from PostgreSQL
const dbProjects = await prisma.project.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
include: {
translations: {
where: { locale },
select: { title: true, description: true },
select: { title: true, description: true, locale: true },
},
},
});
const localized = projects.map((p) => {
const tr = p.translations?.[0];
const { translations: _translations, ...rest } = p;
// 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 {
...rest,
id: p.id,
slug: p.slug,
title: tr?.title ?? p.title,
description: tr?.description ?? p.description,
tags: p.tags,
category: p.category,
date: p.date,
createdAt: p.createdAt.toISOString(),
imageUrl: p.imageUrl,
};
});
return <ProjectsPageClient projects={localized} locale={locale} />;
// 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

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

@@ -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.getByLabelText('Open menu');
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 and passionate/i)).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,31 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
type ActivityFeedComponent = React.ComponentType<Record<string, never>>;
export default function ActivityFeedClient() {
const [Comp, setComp] = useState<ActivityFeedComponent | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const mod = await import("../components/ActivityFeed");
const C = (mod as unknown as { default?: ActivityFeedComponent }).default;
if (!cancelled && typeof C === "function") {
setComp(() => C);
}
} catch {
// ignore
}
})();
return () => {
cancelled = true;
};
}, []);
if (!Comp) return null;
return <Comp />;
}

View File

@@ -5,9 +5,14 @@ import Projects from "../components/Projects";
import Contact from "../components/Contact";
import Footer from "../components/Footer";
import Script from "next/script";
import ActivityFeedClient from "./ActivityFeedClient";
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
@@ -32,15 +37,14 @@ export default function HomePage() {
}),
}}
/>
<ActivityFeedClient />
<Header />
{/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div>
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<Hero />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-24 overflow-hidden">
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
@@ -63,7 +67,7 @@ export default function HomePage() {
<About />
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-24 overflow-hidden">
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"
@@ -86,7 +90,7 @@ export default function HomePage() {
<Projects />
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-24 overflow-hidden">
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120"

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

@@ -1,10 +1,11 @@
"use client";
import { motion } from "framer-motion";
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
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;
@@ -15,10 +16,16 @@ export type ProjectDetailData = {
tags: string[];
featured: boolean;
category: string;
date: 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({
@@ -28,211 +35,136 @@ export default function ProjectDetailClient({
project: ProjectDetailData;
locale: string;
}) {
// Track page view (non-blocking)
const tCommon = useTranslations("common");
const tDetail = useTranslations("projects.detail");
const router = useRouter();
const [canGoBack, setCanGoBack] = useState(false);
useEffect(() => {
try {
navigator.sendBeacon?.(
"/api/analytics/track",
new Blob(
[
JSON.stringify({
type: "pageview",
projectId: project.id.toString(),
page: `/${locale}/projects/${project.slug}`,
}),
],
{ type: "application/json" },
),
);
} catch {
// ignore
// Prüfen, ob wir eine History haben (von Home gekommen)
if (typeof window !== 'undefined' && window.history.length > 1) {
setCanGoBack(true);
}
}, [project.id, project.slug, locale]);
}, []);
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] pt-32 pb-20">
<div className="max-w-4xl mx-auto px-4">
{/* Navigation */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="mb-8"
<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"
>
<Link
href={`/${locale}/projects`}
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-900 transition-colors group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">Back to Projects</span>
</Link>
</motion.div>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">
{tCommon("back")}
</span>
</button>
{/* Header & Meta */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1 }}
className="mb-12"
>
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
<h1 className="text-4xl md:text-6xl font-black font-sans text-stone-900 tracking-tight leading-tight">
{project.title}
</h1>
<div className="flex gap-2 shrink-0 pt-2">
{project.featured && (
<span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
Featured
</span>
)}
<span className="px-4 py-1.5 bg-white border border-stone-200 text-stone-600 text-xs font-medium rounded-full shadow-sm">
{project.category}
</span>
</div>
</div>
<p className="text-xl md:text-2xl text-stone-600 font-light leading-relaxed max-w-3xl mb-8">
{/* 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>
<div className="flex flex-wrap items-center gap-6 text-stone-500 text-sm border-y border-stone-200 py-6">
<div className="flex items-center space-x-2">
<Calendar size={18} />
<span className="font-mono">
{new Date(project.date).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
<div className="h-4 w-px bg-stone-300 hidden sm:block"></div>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span key={tag} className="text-stone-700 font-medium">
#{tag}
</span>
))}
{/* 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>
</motion.div>
{/* Featured Image / Fallback */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
>
{project.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={project.imageUrl} alt={project.title} className="w-full h-full object-cover" />
) : (
<div className="absolute inset-0 bg-gradient-to-br from-stone-200 to-stone-300 flex items-center justify-center">
<span className="text-9xl font-serif font-bold text-stone-500/20 select-none">
{project.title.charAt(0)}
</span>
</div>
)}
</motion.div>
<div className="lg:col-span-4 space-y-8">
{/* Content & Sidebar Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Main Content */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.3 }}
className="lg:col-span-2"
>
<div className="markdown prose prose-stone max-w-none prose-lg prose-headings:font-bold prose-headings:tracking-tight prose-a:text-stone-900 prose-a:decoration-stone-300 hover:prose-a:decoration-stone-900 prose-img:rounded-xl prose-img:shadow-lg">
<ReactMarkdown
components={{
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-stone-900 mt-8 mb-4">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-stone-900 mt-8 mb-4">{children}</h2>
),
p: ({ children }) => <p className="text-stone-700 leading-relaxed mb-6">{children}</p>,
li: ({ children }) => <li className="text-stone-700">{children}</li>,
code: ({ children }) => (
<code className="bg-stone-100 text-stone-800 px-1.5 py-0.5 rounded text-sm font-mono font-medium">
{children}
</code>
),
pre: ({ children }) => (
<pre className="bg-stone-900 text-stone-50 p-6 rounded-xl overflow-x-auto my-6 shadow-lg">
{children}
</pre>
),
}}
>
{project.content}
</ReactMarkdown>
</div>
</motion.div>
{/* Sidebar / Actions */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.4 }}
className="lg:col-span-1 space-y-8"
>
<div className="bg-white/50 backdrop-blur-xl border border-white/60 p-6 rounded-2xl shadow-sm sticky top-32">
<h3 className="font-bold text-stone-900 mb-4 flex items-center gap-2">
<Share2 size={18} />
Project Links
</h3>
<div className="space-y-3">
{project.live && project.live.trim() && project.live !== "#" ? (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-4 py-3 bg-stone-900 text-stone-50 rounded-xl font-medium hover:bg-stone-800 hover:scale-[1.02] transition-all shadow-md group"
>
<span>Live Demo</span>
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
</a>
) : (
<div className="px-4 py-3 bg-stone-100 text-stone-400 rounded-xl font-medium text-sm text-center border border-stone-200 cursor-not-allowed">
Live demo not available
</div>
)}
{/* Quick Links Box - Only show if links exist */}
{project.github && project.github.trim() && project.github !== "#" ? (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-4 py-3 bg-white border border-stone-200 text-stone-700 rounded-xl font-medium hover:bg-stone-50 hover:text-stone-900 hover:border-stone-300 transition-all shadow-sm group"
>
<span>View Source</span>
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
</a>
) : null}
</div>
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
<div className="mt-8 pt-6 border-t border-stone-100">
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">Tech Stack</h4>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-stone-100 text-stone-600 text-xs font-medium rounded-md border border-stone-200"
>
{tag}
</span>
))}
</div>
<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>
</motion.div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,21 +2,21 @@
import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
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;
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
slug: string;
title: string;
description: string;
content: string;
tags: string[];
featured: boolean;
category: string;
date: string;
github?: string | null;
live?: string | null;
date?: string;
createdAt?: string;
imageUrl?: string | null;
};
@@ -27,266 +27,131 @@ export default function ProjectsPageClient({
projects: ProjectListItem[];
locale: string;
}) {
const [selectedCategory, setSelectedCategory] = useState("All");
const tCommon = useTranslations("common");
const tList = useTranslations("projects.list");
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
setMounted(true);
// 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];
return ["all", ...unique];
}, [projects]);
const filteredProjects = useMemo(() => {
let result = projects;
if (selectedCategory !== "All") {
if (selectedCategory !== "all") {
result = result.filter((project) => project.category === selectedCategory);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(project) =>
project.title.toLowerCase().includes(query) ||
project.description.toLowerCase().includes(query) ||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
);
}
return result;
}, [projects, selectedCategory, searchQuery]);
if (!mounted) return null;
return (
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
<div className="max-w-7xl mx-auto px-4">
<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 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="mb-12"
>
<div className="mb-24">
<Link
href={`/${locale}`}
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
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>Back to Home</span>
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
</Link>
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
My Projects
<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="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">
Explore my portfolio of projects, from web applications to mobile apps. Each project showcases different
skills and technologies.
<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>
</motion.div>
</div>
{/* Filters & Search */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="mb-12 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center"
>
{/* Categories */}
{/* 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((category) => (
{categories.map((cat) => (
<button
key={category}
onClick={() => setSelectedCategory(category)}
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
selectedCategory === category
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md"
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
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"
}`}
>
{category}
{cat === 'all' ? tList('all') : cat}
</button>
))}
</div>
{/* Search */}
<div className="relative w-full md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<input
type="text"
placeholder="Search projects..."
<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 pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
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>
</motion.div>
{/* Projects Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredProjects.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
whileHover={{ y: -8 }}
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
>
{/* Image / Fallback / Cover Area */}
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={project.imageUrl}
alt={project.title}
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : (
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
</span>
</div>
</div>
)}
{/* Texture/Grain Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{project.featured && (
<div className="absolute top-3 left-3 z-20">
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
Featured
</div>
</div>
)}
{/* Overlay Links */}
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="GitHub"
onClick={(e) => e.stopPropagation()}
>
<Github size={20} />
</a>
)}
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="Live Demo"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={20} />
</a>
)}
</div>
</div>
<div className="p-6 flex flex-col flex-1">
{/* Stretched Link covering the whole card (including image area) */}
<Link
href={`/${locale}/projects/${project.slug}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title}
</h3>
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
<Calendar size={12} />
<span>{new Date(project.date).getFullYear()}</span>
</div>
</div>
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">{project.description}</p>
<div className="flex flex-wrap gap-2 mb-6">
{project.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
>
{tag}
</span>
))}
{project.tags.length > 4 && (
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
)}
</div>
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
<div className="flex gap-3">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div>
</div>
</div>
</motion.div>
))}
</div>
{filteredProjects.length === 0 && (
<div className="text-center py-20">
<p className="text-stone-500 text-lg">No projects found matching your criteria.</p>
<button
onClick={() => {
setSelectedCategory("All");
setSearchQuery("");
}}
className="mt-4 text-stone-800 font-medium hover:underline"
>
Clear filters
</button>
</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,174 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, projectService } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Rate limiting - more generous for admin dashboard
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 20, 60000)
}
}
);
}
// Admin-only endpoint: require explicit admin header AND a valid signed session token
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
// Check cache first (but allow bypass with cache-bust parameter)
const url = new URL(request.url);
const bypassCache = url.searchParams.get('nocache') === 'true';
if (!bypassCache) {
const cachedStats = await analyticsCache.getOverallStats();
if (cachedStats) {
return NextResponse.json(cachedStats);
}
}
// Get analytics data
const projectsResult = await projectService.getAllProjects();
const projects = projectsResult.projects || projectsResult;
const performanceStats = await projectService.getPerformanceStats();
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Use DB aggregation instead of loading every PageView row into memory
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
prisma.pageView.groupBy({
by: ['ip'],
where: {
timestamp: { gte: since },
ip: { not: null },
},
_count: { _all: true },
_min: { timestamp: true },
_max: { timestamp: true },
}),
prisma.pageView.groupBy({
by: ['projectId'],
where: {
timestamp: { gte: since },
projectId: { not: null },
},
_count: { _all: true },
}),
]);
const totalSessions = sessionsByIp.length;
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
const sessionDurationsMs = sessionsByIp
.map(s => {
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
if (count < 2) return 0;
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
if (!minTs || !maxTs) return 0;
return maxTs.getTime() - minTs.getTime();
})
.filter(ms => ms > 0);
const avgSessionDuration = sessionDurationsMs.length > 0
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
: 0;
const totalUsers = totalSessions;
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
const projectId = row.projectId as number | null;
if (projectId != null) {
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
}
return acc;
}, {} as Record<number, number>);
// Calculate analytics metrics
const analytics = {
overview: {
totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length,
featuredProjects: projects.filter(p => p.featured).length,
totalViews, // Real views from PageView table
totalLikes: 0, // Not implemented - no like buttons
totalShares: 0, // Not implemented - no share buttons
avgLighthouse: (() => {
// Only calculate if we have real performance data (not defaults)
const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
const lighthouse = perf.lighthouse as number || 0;
return lighthouse > 0; // Only count projects with actual performance data
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
: 0;
})()
},
projects: projects.map(project => ({
id: project.id,
title: project.title,
category: project.category,
difficulty: project.difficulty,
views: viewsByProject[project.id] || 0, // Only real views from PageView table
likes: 0, // Not implemented
shares: 0, // Not implemented
lighthouse: (() => {
const perf = (project.performance as Record<string, unknown>) || {};
const score = perf.lighthouse as number || 0;
return score > 0 ? score : 0; // Only return if we have real data
})(),
published: project.published,
featured: project.featured,
createdAt: project.createdAt,
updatedAt: project.updatedAt
})),
categories: performanceStats.byCategory,
difficulties: performanceStats.byDifficulty,
performance: {
avgLighthouse: (() => {
const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
return (perf.lighthouse as number || 0) > 0;
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
: 0;
})(),
totalViews, // Real total views
totalLikes: 0,
totalShares: 0
},
metrics: {
bounceRate,
avgSessionDuration,
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
newUsers: totalUsers,
totalUsers
}
};
// Cache the results
await analyticsCache.setOverallStats(analytics);
return NextResponse.json(analytics);
} catch (error) {
console.error('Analytics dashboard error:', error);
return NextResponse.json(
{ error: 'Failed to fetch analytics data' },
{ status: 500 }
);
}
}

View File

@@ -1,139 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireSessionAuth } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Admin-only endpoint
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
// Get performance data from database
const pageViews = await prisma.pageView.findMany({
orderBy: { timestamp: 'desc' },
take: 1000 // Last 1000 page views
});
const userInteractions = await prisma.userInteraction.findMany({
orderBy: { timestamp: 'desc' },
take: 1000 // Last 1000 interactions
});
// Get all projects for performance data
const projects = await prisma.project.findMany();
// Calculate real performance metrics from projects
const projectsWithPerformance = projects.map(p => ({
id: p.id,
title: p.title,
lighthouse: ((p.performance as Record<string, unknown>)?.lighthouse as number) || 0,
loadTime: ((p.performance as Record<string, unknown>)?.loadTime as number) || 0,
fcp: ((p.performance as Record<string, unknown>)?.firstContentfulPaint as number) || 0,
lcp: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.lcp as number || 0,
cls: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.cls as number || 0
}));
// Calculate average lighthouse score (currently unused but kept for future use)
const _avgLighthouse = projectsWithPerformance.length > 0
? Math.round(projectsWithPerformance.reduce((sum, p) => sum + p.lighthouse, 0) / projectsWithPerformance.length)
: 0;
// Calculate bounce rate from page views
const pageViewsByIP = pageViews.reduce((acc, pv) => {
const ip = pv.ip || 'unknown';
if (!acc[ip]) acc[ip] = [];
acc[ip].push(pv);
return acc;
}, {} as Record<string, typeof pageViews>);
const totalSessions = Object.keys(pageViewsByIP).length;
const bouncedSessions = Object.values(pageViewsByIP).filter(session => session.length === 1).length;
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
// Calculate average session duration
const sessionDurations = Object.values(pageViewsByIP)
.map(session => {
if (session.length < 2) return 0;
const sorted = session.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
return sorted[sorted.length - 1].timestamp.getTime() - sorted[0].timestamp.getTime();
})
.filter(d => d > 0);
const avgSessionDuration = sessionDurations.length > 0
? Math.round(sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length / 1000) // in seconds
: 0;
// Calculate pages per session
const pagesPerSession = totalSessions > 0 ? (pageViews.length / totalSessions).toFixed(1) : '0';
// Calculate performance metrics
const performance = {
avgLighthouse: (() => {
const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
return (perf.lighthouse as number || 0) > 0;
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => {
const perf = (p.performance as Record<string, unknown>) || {};
return sum + (perf.lighthouse as number || 0);
}, 0) / projectsWithPerf.length)
: 0;
})(),
totalViews: pageViews.length,
metrics: {
bounceRate,
avgSessionDuration: avgSessionDuration,
pagesPerSession: parseFloat(pagesPerSession),
newUsers: new Set(pageViews.map(pv => pv.ip).filter(Boolean)).size
},
pageViews: {
total: pageViews.length,
last24h: pageViews.filter(pv => {
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return new Date(pv.timestamp) > dayAgo;
}).length,
last7d: pageViews.filter(pv => {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
return new Date(pv.timestamp) > weekAgo;
}).length,
last30d: pageViews.filter(pv => {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(pv.timestamp) > monthAgo;
}).length
},
interactions: {
total: userInteractions.length,
last24h: userInteractions.filter(ui => {
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
return new Date(ui.timestamp) > dayAgo;
}).length,
last7d: userInteractions.filter(ui => {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
return new Date(ui.timestamp) > weekAgo;
}).length,
last30d: userInteractions.filter(ui => {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return new Date(ui.timestamp) > monthAgo;
}).length
},
topPages: pageViews.reduce((acc, pv) => {
acc[pv.page] = (acc[pv.page] || 0) + 1;
return acc;
}, {} as Record<string, number>),
topInteractions: userInteractions.reduce((acc, ui) => {
acc[ui.type] = (acc[ui.type] || 0) + 1;
return acc;
}, {} as Record<string, number>)
};
return NextResponse.json(performance);
} catch (error) {
console.error('Performance analytics error:', error);
return NextResponse.json(
{ error: 'Failed to fetch performance data' },
{ status: 500 }
);
}
}

View File

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

View File

@@ -1,51 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
// Rate limiting for POST requests
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for analytics
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 30, 60000)
}
}
);
}
const body = await request.json();
// Log performance metrics (you can extend this to store in database)
if (process.env.NODE_ENV === 'development') {
console.log('Performance Metric:', {
timestamp: new Date().toISOString(),
...body,
});
}
// You could store this in a database or send to external service
// For now, we'll just log it since Umami handles the main analytics
return NextResponse.json({ success: true });
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Analytics API Error:', error);
}
return NextResponse.json(
{ error: 'Failed to process analytics data' },
{ status: 500 }
);
}
}
export async function GET() {
return NextResponse.json({
message: 'Analytics API is running',
timestamp: new Date().toISOString(),
});
}

View File

@@ -1,187 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function POST(request: NextRequest) {
try {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 100, 60000)) { // 100 requests per minute for tracking
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 100, 60000)
}
}
);
}
const body = await request.json();
const { type, projectId, page, performance, session } = body;
const userAgent = request.headers.get('user-agent') || undefined;
const referrer = request.headers.get('referer') || undefined;
// Track page view
if (type === 'pageview' && page) {
let projectIdNum: number | null = null;
if (projectId != null) {
const raw = projectId.toString();
const parsed = parseInt(raw, 10);
if (Number.isFinite(parsed)) {
projectIdNum = parsed;
} else {
const bySlug = await prisma.project.findFirst({
where: { slug: raw },
select: { id: true },
});
projectIdNum = bySlug?.id ?? null;
}
}
// Create page view record
await prisma.pageView.create({
data: {
projectId: projectIdNum,
page,
ip,
userAgent,
referrer
}
});
// Update project analytics if projectId exists
if (projectIdNum) {
const project = await prisma.project.findUnique({
where: { id: projectIdNum }
});
if (project) {
const analytics = (project.analytics as Record<string, unknown>) || {};
const currentViews = (analytics.views as number) || 0;
await prisma.project.update({
where: { id: projectIdNum },
data: {
analytics: {
...analytics,
views: currentViews + 1,
lastUpdated: new Date().toISOString()
}
}
});
}
}
}
// Track performance metrics
if (type === 'performance' && performance) {
// Try to get projectId from page path if not provided
let projectIdNum: number | null = null;
if (projectId) {
projectIdNum = parseInt(projectId.toString());
} else if (page) {
// Try to extract from page path like /projects/123 or /projects/slug
const match = page.match(/\/projects\/(\d+)/);
if (match) {
projectIdNum = parseInt(match[1]);
} else {
// Try to find by slug
const slugMatch = page.match(/\/projects\/([^\/]+)/);
if (slugMatch) {
const slug = slugMatch[1];
const project = await prisma.project.findFirst({
where: {
OR: [
{ id: parseInt(slug) || 0 },
{ slug }
]
}
});
if (project) projectIdNum = project.id;
}
}
}
if (projectIdNum) {
const project = await prisma.project.findUnique({
where: { id: projectIdNum }
});
if (project) {
const perf = (project.performance as Record<string, unknown>) || {};
const analytics = (project.analytics as Record<string, unknown>) || {};
// Calculate lighthouse score from web vitals
const lcp = performance.lcp || 0;
const fid = performance.fid || 0;
const cls = performance.cls || 0;
const fcp = performance.fcp || 0;
const ttfb = performance.ttfb || 0;
// Only calculate lighthouse score if we have real web vitals data
// Check if we have at least LCP and FCP (most important metrics)
if (lcp > 0 || fcp > 0) {
// Simple lighthouse score calculation (0-100)
let lighthouseScore = 100;
if (lcp > 4000) lighthouseScore -= 25;
else if (lcp > 2500) lighthouseScore -= 15;
if (fid > 300) lighthouseScore -= 25;
else if (fid > 100) lighthouseScore -= 15;
if (cls > 0.25) lighthouseScore -= 25;
else if (cls > 0.1) lighthouseScore -= 15;
if (fcp > 3000) lighthouseScore -= 15;
if (ttfb > 800) lighthouseScore -= 10;
lighthouseScore = Math.max(0, Math.min(100, lighthouseScore));
await prisma.project.update({
where: { id: projectIdNum },
data: {
performance: {
...perf,
lighthouse: lighthouseScore,
loadTime: performance.loadTime || perf.loadTime || 0,
firstContentfulPaint: fcp || perf.firstContentfulPaint || 0,
largestContentfulPaint: lcp || perf.largestContentfulPaint || 0,
cumulativeLayoutShift: cls || perf.cumulativeLayoutShift || 0,
totalBlockingTime: performance.tbt || perf.totalBlockingTime || 0,
speedIndex: performance.si || perf.speedIndex || 0,
coreWebVitals: {
lcp: lcp || (perf.coreWebVitals as Record<string, unknown>)?.lcp || 0,
fid: fid || (perf.coreWebVitals as Record<string, unknown>)?.fid || 0,
cls: cls || (perf.coreWebVitals as Record<string, unknown>)?.cls || 0
},
lastUpdated: new Date().toISOString()
},
analytics: {
...analytics,
lastUpdated: new Date().toISOString()
}
}
});
}
}
}
}
// Track session data (for bounce rate calculation)
if (type === 'session' && session) {
// Store session data in a way that allows bounce rate calculation
// A bounce is a session with only one pageview
// We'll track this via PageView records and calculate bounce rate from them
}
return NextResponse.json({ success: true });
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Analytics tracking error:', error);
}
return NextResponse.json(
{ error: 'Failed to track analytics' },
{ status: 500 }
);
}
}

View File

@@ -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,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { getContentByKey } from "@/lib/content";
import { getContentPage } from "@/lib/directus";
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@@ -11,9 +14,35 @@ export async function GET(request: NextRequest) {
}
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 });
return NextResponse.json({ content: translation });
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") {

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

View File

@@ -44,10 +44,12 @@ export async function POST(request: NextRequest) {
// Ensure URL doesn't have trailing slash before adding /webhook/chat
const baseUrl = n8nWebhookUrl.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/webhook/chat`;
console.log(`Sending to n8n: ${webhookUrl}`, {
hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY,
});
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();
@@ -76,20 +78,24 @@ export async function POST(request: NextRequest) {
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
console.error(`n8n webhook failed with status: ${response.status}`, {
status: response.status,
statusText: response.statusText,
error: errorText,
webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs
});
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();
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));
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;

View File

@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
: ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
@@ -43,7 +43,9 @@ export async function GET(request: NextRequest) {
// Rufe den n8n Webhook auf
// Add timestamp to query to bypass Cloudflare cache
const webhookUrl = `${n8nWebhookUrl}/webhook/hardcover/currently-reading?t=${Date.now()}`;
console.log(`Fetching currently reading from: ${webhookUrl}`);
if (process.env.NODE_ENV === 'development') {
console.log(`Fetching currently reading from: ${webhookUrl}`);
}
// Add timeout to prevent hanging requests
const controller = new AbortController();

View File

@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
: ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute
return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 }
@@ -31,7 +31,9 @@ export async function GET(request: NextRequest) {
const n8nWebhookUrl = process.env.N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
console.warn("N8N_WEBHOOK_URL not configured for status endpoint");
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" },
@@ -44,7 +46,9 @@ export async function GET(request: NextRequest) {
// Rufe den n8n Webhook auf
// Add timestamp to query to bypass Cloudflare cache
const statusUrl = `${n8nWebhookUrl}/webhook/denshooter-71242/status?t=${Date.now()}`;
console.log(`Fetching status from: ${statusUrl}`);
if (process.env.NODE_ENV === 'development') {
console.log(`Fetching status from: ${statusUrl}`);
}
// Add timeout to prevent hanging requests
const controller = new AbortController();
@@ -68,7 +72,9 @@ export async function GET(request: NextRequest) {
if (!res.ok) {
const errorText = await res.text().catch(() => 'Unknown error');
console.error(`n8n status webhook failed: ${res.status}`, errorText);
if (process.env.NODE_ENV === 'development') {
console.error(`n8n status webhook failed: ${res.status}`, errorText);
}
throw new Error(`n8n error: ${res.status} - ${errorText}`);
}
@@ -108,20 +114,24 @@ export async function GET(request: NextRequest) {
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.error("n8n status webhook request timed out");
} else {
console.error("n8n status webhook fetch error:", fetchError);
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) {
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',
});
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" },

View File

@@ -42,11 +42,13 @@ export async function PUT(
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 });
@@ -59,10 +61,12 @@ export async function PUT(
locale,
title,
description,
content: content ?? undefined,
},
update: {
title,
description,
content: content ?? undefined,
},
});

View File

@@ -4,6 +4,8 @@ import { apiCache } from '@/lib/cache';
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 {
@@ -40,46 +42,80 @@ export async function GET(request: NextRequest) {
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
const category = searchParams.get('category');
const featured = searchParams.get('featured');
const published = searchParams.get('published');
const published = searchParams.get('published') === 'false' ? false : true; // Default to true if not specified
const difficulty = searchParams.get('difficulty');
const search = searchParams.get('search');
const locale = searchParams.get('locale') || 'en';
// 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' },
@@ -89,19 +125,31 @@ 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) {
// Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {

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,56 +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';
// Use Directus instead of Prisma
const projects = await getProjects(locale, {
featured: undefined,
published: true,
category: category && category !== 'All' ? category : undefined,
search: search || undefined,
});
if (!projects) {
// Directus not available or no projects found
return NextResponse.json({ projects: [] });
}
// Filter by slug if provided (since Directus query doesn't support slug filter directly)
if (slug) {
const project = await prisma.project.findFirst({
where: {
published: true,
slug,
},
orderBy: { createdAt: 'desc' },
});
const project = projects.find(p => p.slug === slug);
return NextResponse.json({ projects: project ? [project] : [] });
}
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 });
}
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 });
}

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

View File

@@ -1,256 +1,386 @@
"use client";
import { motion, Variants } from "framer-motion";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
import { useEffect, useState } from "react";
import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading";
import ReadBooks from "./ReadBooks";
import { motion, AnimatePresence } from "framer-motion";
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 staggerContainer: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
delayChildren: 0.2,
},
},
};
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
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(() => {
(async () => {
const fetchData = async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
} else {
setCmsDoc(null);
}
} catch {
// ignore; fallback to static
setCmsDoc(null);
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 techStack = [
{
category: t("techStack.categories.frontendMobile"),
icon: Globe,
items: ["Next.js", "Tailwind CSS", "Flutter"],
},
{
category: t("techStack.categories.backendDevops"),
icon: Server,
items: ["Docker Swarm", "Traefik", "Nginx Proxy Manager", "Redis"],
},
{
category: t("techStack.categories.toolsAutomation"),
icon: Wrench,
items: ["Git", "CI/CD", "n8n", t("techStack.items.selfHostedServices")],
},
{
category: t("techStack.categories.securityAdmin"),
icon: Shield,
items: ["CrowdSec", "Suricata", "Mailcow"],
},
];
const hobbies: Array<{ icon: typeof Code; text: string }> = [
{ icon: Code, text: t("hobbies.selfHosting") },
{ icon: Gamepad2, text: t("hobbies.gaming") },
{ icon: Server, text: t("hobbies.gameServers") },
{ icon: Activity, text: t("hobbies.jogging") },
];
const copyToClipboard = (code: string) => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<section
id="about"
className="py-24 px-4 bg-gradient-to-br from-liquid-sky/15 via-liquid-lavender/10 to-liquid-pink/15 relative overflow-hidden"
>
<div className="max-w-6xl mx-auto relative z-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
{/* Text Content */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={staggerContainer}
className="space-y-8"
<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"
>
<motion.h2
variants={fadeInUp}
className="text-4xl md:text-5xl font-bold text-stone-900"
>
{t("title")}
</motion.h2>
<motion.div
variants={fadeInUp}
className="prose prose-stone prose-lg text-stone-700 space-y-4"
>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
) : (
<>
<p>{t("p1")}</p>
<p>{t("p2")}</p>
<p>{t("p3")}</p>
</>
)}
<motion.div
variants={fadeInUp}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-lavender/15 border-2 border-liquid-mint/30 rounded-xl p-5 backdrop-blur-sm"
>
<div className="flex items-start gap-3">
<Lightbulb size={20} className="text-stone-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-stone-800 mb-1">
{t("funFactTitle")}
</p>
<p className="text-sm text-stone-700 leading-relaxed">
{t("funFactBody")}
</p>
<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>
</motion.div>
</motion.div>
</div>
</div>
</motion.div>
{/* Tech Stack & Hobbies */}
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={staggerContainer}
className="space-y-8"
{/* 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>
<motion.h3
variants={fadeInUp}
className="text-2xl font-bold text-stone-900 mb-6"
>
{t("techStackTitle")}
</motion.h3>
<div className="grid grid-cols-1 gap-4">
{techStack.map((stack, idx) => (
<motion.div
key={`${stack.category}-${idx}`}
variants={fadeInUp}
whileHover={{
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className={`p-5 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out ${
idx === 0
? "bg-gradient-to-br from-liquid-sky/10 to-liquid-mint/10 border-liquid-sky/30 hover:border-liquid-sky/50 hover:from-liquid-sky/15 hover:to-liquid-mint/15"
: idx === 1
? "bg-gradient-to-br from-liquid-peach/10 to-liquid-coral/10 border-liquid-peach/30 hover:border-liquid-peach/50 hover:from-liquid-peach/15 hover:to-liquid-coral/15"
: idx === 2
? "bg-gradient-to-br from-liquid-lavender/10 to-liquid-pink/10 border-liquid-lavender/30 hover:border-liquid-lavender/50 hover:from-liquid-lavender/15 hover:to-liquid-pink/15"
: "bg-gradient-to-br from-liquid-teal/10 to-liquid-lime/10 border-liquid-teal/30 hover:border-liquid-teal/50 hover:from-liquid-teal/15 hover:to-liquid-lime/15"
}`}
>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-white rounded-lg shadow-sm text-stone-700">
<stack.icon size={18} />
</div>
<h4 className="font-semibold text-stone-800">
{stack.category}
</h4>
</div>
<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">
{stack.items.map((item, itemIdx) => (
<span
key={`${stack.category}-${item}-${itemIdx}`}
className={`px-3 py-1.5 rounded-lg border-2 text-sm text-stone-700 font-medium transition-all duration-400 ease-out ${
itemIdx % 4 === 0
? "bg-liquid-mint/10 border-liquid-mint/30 hover:bg-liquid-mint/20 hover:border-liquid-mint/50"
: itemIdx % 4 === 1
? "bg-liquid-lavender/10 border-liquid-lavender/30 hover:bg-liquid-lavender/20 hover:border-liquid-lavender/50"
: itemIdx % 4 === 2
? "bg-liquid-rose/10 border-liquid-rose/30 hover:bg-liquid-rose/20 hover:border-liquid-rose/50"
: "bg-liquid-sky/10 border-liquid-sky/30 hover:bg-liquid-sky/20 hover:border-liquid-sky/50"
}`}
>
{item}
<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>
</motion.div>
))}
</div>
</div>
))
)}
</div>
{/* Hobbies */}
<div>
<motion.h3
variants={fadeInUp}
className="text-xl font-bold text-stone-900 mb-4"
>
{t("hobbiesTitle")}
</motion.h3>
<div className="space-y-3">
{hobbies.map((hobby, idx) => (
<motion.div
key={`hobby-${hobby.text}-${idx}`}
variants={fadeInUp}
whileHover={{
x: 8,
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out ${
idx === 0
? "bg-gradient-to-r from-liquid-mint/10 to-liquid-sky/10 border-liquid-mint/30 hover:border-liquid-mint/50 hover:from-liquid-mint/15 hover:to-liquid-sky/15"
: idx === 1
? "bg-gradient-to-r from-liquid-coral/10 to-liquid-peach/10 border-liquid-coral/30 hover:border-liquid-coral/50 hover:from-liquid-coral/15 hover:to-liquid-peach/15"
: idx === 2
? "bg-gradient-to-r from-liquid-lavender/10 to-liquid-pink/10 border-liquid-lavender/30 hover:border-liquid-lavender/50 hover:from-liquid-lavender/15 hover:to-liquid-pink/15"
: "bg-gradient-to-r from-liquid-lime/10 to-liquid-teal/10 border-liquid-lime/30 hover:border-liquid-lime/50 hover:from-liquid-lime/15 hover:to-liquid-teal/15"
}`}
>
<hobby.icon size={20} className="text-stone-600" />
<span className="text-stone-700 font-medium">
{hobby.text}
</span>
</motion.div>
))}
</div>
</div>
{/* Currently Reading */}
<motion.div
variants={fadeInUp}
className="mt-8"
>
<CurrentlyReading />
</motion.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>
);
};

File diff suppressed because it is too large Load Diff

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

@@ -5,19 +5,19 @@ import { usePathname } from "next/navigation";
import dynamic from "next/dynamic";
import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary";
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
import { ConsentProvider, useConsent } from "./ConsentProvider";
import { ConsentProvider } from "./ConsentProvider";
import { ThemeProvider } from "./ThemeProvider";
import { motion, AnimatePresence } from "framer-motion";
// Dynamic import with SSR disabled to avoid framer-motion issues
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
const ShaderGradientBackground = dynamic(
() => import("./ShaderGradientBackground"),
{ ssr: false, loading: () => null }
);
export default function ClientProviders({
children,
@@ -72,9 +72,21 @@ export default function ClientProviders({
<ErrorBoundary>
<ErrorBoundary>
<ConsentProvider>
<GatedProviders mounted={mounted} is404Page={is404Page}>
{children}
</GatedProviders>
<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>
@@ -84,30 +96,18 @@ export default function ClientProviders({
function GatedProviders({
children,
mounted,
is404Page,
}: {
children: React.ReactNode;
mounted: boolean;
is404Page: boolean;
}) {
const { consent } = useConsent();
const pathname = usePathname();
const isAdminRoute = pathname.startsWith("/manage") || pathname.startsWith("/editor");
// If consent is not decided yet, treat optional features as off
const analyticsEnabled = !!consent?.analytics;
const chatEnabled = !!consent?.chat;
const content = (
return (
<ErrorBoundary>
<ToastProvider>
{mounted && <BackgroundBlobs />}
{mounted && <ShaderGradientBackground />}
<div className="relative z-10">{children}</div>
{mounted && !is404Page && !isAdminRoute && chatEnabled && <ChatWidget />}
</ToastProvider>
</ErrorBoundary>
);
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
}

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

View File

@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
export default function ConsentBanner() {
const { consent, ready, setConsent } = useConsent();
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
const [draft, setDraft] = useState<ConsentState>({ chat: false });
const [minimized, setMinimized] = useState(false);
const t = useTranslations("consent");
@@ -19,7 +19,6 @@ export default function ConsentBanner() {
title: t("title"),
description: t("description"),
essential: t("essential"),
analytics: t("analytics"),
chat: t("chat"),
alwaysOn: t("alwaysOn"),
acceptAll: t("acceptAll"),
@@ -68,16 +67,6 @@ export default function ConsentBanner() {
<div className="text-[11px] text-stone-500">{s.alwaysOn}</div>
</div>
<label className="flex items-center justify-between gap-3 py-1">
<span className="text-sm font-semibold text-stone-800">{s.analytics}</span>
<input
type="checkbox"
checked={draft.analytics}
onChange={(e) => setDraft((p) => ({ ...p, analytics: e.target.checked }))}
className="w-4 h-4 accent-stone-900"
/>
</label>
<label className="flex items-center justify-between gap-3 py-1">
<span className="text-sm font-semibold text-stone-800">{s.chat}</span>
<input
@@ -91,7 +80,7 @@ export default function ConsentBanner() {
<div className="mt-3 flex flex-col gap-2">
<button
onClick={() => setConsent({ analytics: true, chat: true })}
onClick={() => setConsent({ chat: true })}
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
>
{s.acceptAll}
@@ -103,7 +92,7 @@ export default function ConsentBanner() {
{s.acceptSelected}
</button>
<button
onClick={() => setConsent({ analytics: false, chat: false })}
onClick={() => setConsent({ chat: false })}
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
>
{s.rejectAll}

View File

@@ -3,7 +3,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
export type ConsentState = {
analytics: boolean;
chat: boolean;
};
@@ -20,7 +19,6 @@ function readConsentFromCookie(): ConsentState | null {
try {
const parsed = JSON.parse(value) as Partial<ConsentState>;
return {
analytics: !!parsed.analytics,
chat: !!parsed.chat,
};
} catch {

View File

@@ -2,11 +2,12 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { Mail, MapPin, Send } from "lucide-react";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
const Contact = () => {
const { showEmailSent, showEmailError } = useToast();
@@ -152,118 +153,120 @@ const Contact = () => {
validateForm();
};
const contactInfo = [
{
icon: Mail,
title: tInfo("email"),
value: "contact@dk0.dev",
href: "mailto:contact@dk0.dev",
},
{
icon: MapPin,
title: tInfo("location"),
value: tInfo("locationValue"),
},
];
return (
<section
id="contact"
className="py-24 px-4 relative bg-gradient-to-br from-liquid-teal/15 via-liquid-mint/10 to-liquid-lime/15"
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
>
<div className="max-w-7xl mx-auto">
{/* Section Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="text-center mb-16"
>
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
{t("title")}
</h2>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
) : (
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
{t("subtitle")}
</p>
)}
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact Information */}
{/* Header Card */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="space-y-8"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div>
<h3 className="text-2xl font-bold text-stone-900 mb-6">
{t("getInTouch")}
</h3>
<p className="text-stone-700 leading-relaxed">
{t("getInTouchBody")}
</p>
</div>
{/* Contact Details */}
<div className="space-y-4">
{contactInfo.map((info, index) => (
<motion.a
key={info.title}
href={info.href}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.5,
delay: index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{
x: 8,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-[background-color,border-color,box-shadow] duration-500 ease-out group border-transparent hover:border-white/70"
>
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
<info.icon className="w-6 h-6 text-stone-700" />
</div>
<div>
<h4 className="font-semibold text-stone-800">
{info.title}
</h4>
<p className="text-stone-500">{info.value}</p>
</div>
</motion.a>
))}
<div className="max-w-3xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
) : (
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")}
</p>
)}
</div>
</motion.div>
{/* Contact Form */}
{/* Info Side (Unified Connect Box) */}
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
>
<h3 className="text-2xl font-bold text-gray-800 mb-6">
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
<div className="relative z-10">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
</div>
</div>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
{/* Email */}
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Mail size={16} />
</div>
</a>
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
{/* GitHub */}
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Github size={16} />
</div>
</a>
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
{/* LinkedIn */}
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col">
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
</div>
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
<Linkedin size={16} />
</div>
</a>
</div>
</div>
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
<MapPin size={14} className="text-liquid-mint" />
<span className="font-bold">{tInfo("locationValue")}</span>
</div>
</div>
</div>
</motion.div>
{/* Form Side */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
{tForm("title")}
</h3>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label
htmlFor="name"
className="block text-sm font-medium text-stone-600 mb-2"
>
Name <span className="text-liquid-rose">*</span>
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.name")}
</label>
<input
type="text"
@@ -273,32 +276,14 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
errors.name && touched.name
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.name")}
aria-invalid={
errors.name && touched.name ? "true" : "false"
}
aria-describedby={
errors.name && touched.name ? "name-error" : undefined
}
/>
{errors.name && touched.name && (
<p id="name-error" className="mt-1 text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-stone-600 mb-2"
>
Email <span className="text-liquid-rose">*</span>
<div className="space-y-2">
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.email")}
</label>
<input
type="email"
@@ -308,33 +293,15 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
errors.email && touched.email
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.email")}
aria-invalid={
errors.email && touched.email ? "true" : "false"
}
aria-describedby={
errors.email && touched.email ? "email-error" : undefined
}
/>
{errors.email && touched.email && (
<p id="email-error" className="mt-1 text-sm text-red-500">
{errors.email}
</p>
)}
</div>
</div>
<div>
<label
htmlFor="subject"
className="block text-sm font-medium text-stone-600 mb-2"
>
Subject <span className="text-liquid-rose">*</span>
<div className="space-y-2">
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.subject")}
</label>
<input
type="text"
@@ -344,34 +311,14 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
errors.subject && touched.subject
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
placeholder={tForm("placeholders.subject")}
aria-invalid={
errors.subject && touched.subject ? "true" : "false"
}
aria-describedby={
errors.subject && touched.subject
? "subject-error"
: undefined
}
/>
{errors.subject && touched.subject && (
<p id="subject-error" className="mt-1 text-sm text-red-500">
{errors.subject}
</p>
)}
</div>
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-stone-600 mb-2"
>
Message <span className="text-liquid-rose">*</span>
<div className="space-y-2">
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.message")}
</label>
<textarea
id="message"
@@ -380,53 +327,25 @@ const Contact = () => {
onChange={handleChange}
onBlur={handleBlur}
required
rows={6}
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all resize-none ${
errors.message && touched.message
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
rows={5}
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
placeholder={tForm("placeholders.message")}
aria-invalid={
errors.message && touched.message ? "true" : "false"
}
aria-describedby={
errors.message && touched.message
? "message-error"
: undefined
}
/>
<div className="flex justify-between items-center mt-1">
{errors.message && touched.message ? (
<p id="message-error" className="text-sm text-red-500">
{errors.message}
</p>
) : (
<span></span>
)}
<span className="text-xs text-stone-400">
{tForm("characters", { count: formData.message.length })}
</span>
</div>
</div>
<motion.button
type="submit"
disabled={isSubmitting}
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
transition={{ duration: 0.3, ease: "easeOut" }}
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-stone-950 transition-all duration-500 ease-out shadow-lg hover:shadow-xl"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
>
{isSubmitting ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>{tForm("sending")}</span>
</>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<>
<Send size={20} />
<span className="text-cream">{tForm("send")}</span>
<Send size={16} />
{tForm("send")}
</>
)}
</motion.button>

View File

@@ -4,6 +4,8 @@ import { motion } from "framer-motion";
import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface CurrentlyReading {
title: string;
@@ -53,8 +55,26 @@ const CurrentlyReading = () => {
fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount
if (loading) {
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4 items-start">
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg shrink-0" />
<div className="flex-1 space-y-3 w-full">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<div className="space-y-2 pt-4">
<Skeleton className="h-2 w-full" />
<Skeleton className="h-2 w-full" />
</div>
</div>
</div>
</div>
);
}
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
if (loading || books.length === 0) {
if (books.length === 0) {
return null;
}
@@ -62,8 +82,8 @@ const CurrentlyReading = () => {
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900">
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} {books.length > 1 && `(${books.length})`}
</h3>
</div>
@@ -80,11 +100,11 @@ const CurrentlyReading = () => {
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 border-2 border-liquid-lavender/30 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
>
{/* Background Blob Animation */}
<motion.div
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 rounded-full blur-2xl"
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
@@ -106,12 +126,13 @@ const CurrentlyReading = () => {
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0"
>
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50">
<img
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={book.image}
alt={book.title}
className="w-full h-full object-cover"
loading="lazy"
fill
className="object-cover"
sizes="(max-width: 640px) 96px, 112px"
/>
{/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
@@ -122,25 +143,25 @@ const CurrentlyReading = () => {
{/* Book Info */}
<div className="flex-1 min-w-0">
{/* Title */}
<h4 className="text-lg font-bold text-stone-900 mb-1 line-clamp-2">
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
{book.title}
</h4>
{/* Authors */}
<p className="text-sm text-stone-600 mb-4 line-clamp-1">
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
{book.authors.join(", ")}
</p>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-stone-600">
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
<span>{t("progress")}</span>
<span className="font-semibold">{book.progress}%</span>
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
</div>
<div className="relative h-2 bg-white/50 rounded-full overflow-hidden border border-white/70">
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${book.progress}%` }}
animate={{ width: `${isNaN(book.progress) ? 0 : book.progress}%` }}
transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
/>

View File

@@ -1,142 +1,77 @@
"use client";
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Heart, Code } from 'lucide-react';
import { SiGithub, SiLinkedin } from 'react-icons/si';
import Link from 'next/link';
import React from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useConsent } from "./ConsentProvider";
import { ArrowUp } from "lucide-react";
const Footer = () => {
const locale = useLocale();
const t = useTranslations("footer");
const { resetConsent } = useConsent();
const year = new Date().getFullYear();
const [currentYear] = useState(() => new Date().getFullYear());
const socialLinks = [
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
];
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<footer className="relative py-12 px-4 bg-white border-t border-stone-200">
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
{/* Brand */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className="flex items-center space-x-3"
>
<motion.div
whileHover={{ rotate: 360, scale: 1.1 }}
transition={{ duration: 0.5 }}
className="w-12 h-12 bg-gradient-to-br from-liquid-mint to-liquid-lavender rounded-xl flex items-center justify-center shadow-md"
>
<Code className="w-6 h-6 text-stone-800" />
</motion.div>
<div>
<Link href={`/${locale}`} className="text-xl font-bold font-mono text-stone-800 hover:text-liquid-blue transition-colors">
dk<span className="text-liquid-rose">0</span>
</Link>
<p className="text-xs text-stone-500">{t("role")}</p>
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
{/* Copyright & Info */}
<div className="md:col-span-4 space-y-6">
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
dk
</div>
</motion.div>
<div className="space-y-2">
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
<p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
</div>
</div>
{/* Social Links */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.05 }}
className="flex space-x-3"
>
{socialLinks.map((social) => (
<motion.a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.15, y: -3 }}
whileTap={{ scale: 0.95 }}
className="p-3 bg-stone-50 hover:bg-white rounded-xl text-stone-600 hover:text-stone-900 transition-all duration-200 border border-stone-200 hover:border-stone-300 shadow-sm"
aria-label={social.label}
>
<social.icon size={18} />
</motion.a>
))}
</motion.div>
{/* Navigation Links */}
<div className="md:col-span-4 grid grid-cols-2 gap-8">
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
<div className="flex flex-col gap-2">
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
</div>
</div>
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
</div>
</div>
</div>
{/* Copyright */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.1 }}
className="flex items-center space-x-2 text-stone-400 text-sm"
>
<span>© {currentYear}</span>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
{/* Back to Top */}
<div className="md:col-span-4 flex justify-start md:justify-end">
<button
onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
<Heart size={14} className="text-liquid-rose fill-liquid-rose" />
</motion.div>
<span>{t("madeIn")}</span>
</motion.div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400 vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
<ArrowUp size={20} />
</div>
</button>
</div>
</div>
{/* Legal Links */}
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.15 }}
className="mt-8 pt-6 border-t border-stone-100 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
>
<div className="flex space-x-6 text-sm">
<Link
href={`/${locale}/legal-notice`}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
>
{t("legalNotice")}
</Link>
<Link
href={`/${locale}/privacy-policy`}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
>
{t("privacyPolicy")}
</Link>
<button
type="button"
onClick={() => resetConsent()}
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
title={t("privacySettingsTitle")}
>
{t("privacySettings")}
</button>
<Link
href="/404"
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"
title="Kernel Panic 404"
>
404
</Link>
{/* Bottom Bar */}
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
Built with Next.js, Directus & Passion.
</p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
</div>
<div className="text-xs text-stone-400 flex items-center space-x-1">
<span>{t("builtWith")}</span>
<span className="text-stone-600 font-semibold">Next.js</span>
<span className="text-stone-300"></span>
<span className="text-stone-600 font-semibold">TypeScript</span>
<span className="text-stone-300"></span>
<span className="text-stone-600 font-semibold">Tailwind CSS</span>
</div>
</motion.div>
</div>
</div>
</footer>
);

View File

@@ -0,0 +1,12 @@
import { getNavTranslations } from '@/lib/translations-loader';
import HeaderClient from './HeaderClient';
interface HeaderProps {
locale: string;
}
export default async function Header({ locale }: HeaderProps) {
const translations = await getNavTranslations(locale);
return <HeaderClient locale={locale} translations={translations} />;
}

View File

@@ -1,32 +1,21 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "./ThemeToggle";
const Header = () => {
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const locale = useLocale();
const pathname = usePathname();
const searchParams = useSearchParams();
const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const navItems = [
{ name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
@@ -34,232 +23,82 @@ const Header = () => {
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
];
const socialLinks = [
{ icon: SiGithub, href: "https://github.com/Denshooter", label: "GitHub" },
{
icon: SiLinkedin,
href: "https://linkedin.com/in/dkonkol",
label: "LinkedIn",
},
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
];
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
const qs = searchParams.toString();
const query = qs ? `?${qs}` : "";
const enHref = `/en${pathWithoutLocale}${query}`;
const deHref = `/de${pathWithoutLocale}${query}`;
// Always render to prevent flash, but use opacity transition
return (
<>
<motion.header
initial={false}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
>
<div
className={`pointer-events-auto transition-all duration-500 ease-out ${
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
}`}
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<motion.nav
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4"
>
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={`
backdrop-blur-xl transition-all duration-500
${
scrolled
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
}
flex justify-between items-center
`}
{/* Logo Pill */}
<Link
href={`/${locale}`}
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
>
<motion.div
whileHover={{ scale: 1.05 }}
className="flex items-center space-x-2"
>
<span className="font-black text-xs tracking-tighter">dk</span>
</Link>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<Link
href={`/${locale}`}
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
key={item.name}
href={item.href}
className="px-5 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-all"
>
dk<span className="text-red-500">0</span>
{item.name}
</Link>
</motion.div>
))}
</div>
<nav className="hidden md:flex items-center space-x-8">
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
{/* Actions Pill */}
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
<Link
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
className="w-8 h-8 flex items-center justify-center text-[10px] font-black text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
>
{locale === "en" ? "DE" : "EN"}
</Link>
<ThemeToggle />
{/* Mobile Menu Toggle */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-8 h-8 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-white dark:hover:bg-stone-800 rounded-full transition-colors shadow-sm"
>
{isOpen ? <X size={14} /> : <Menu size={14} />}
</button>
</div>
</motion.nav>
</div>
{/* Mobile Menu Overlay */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
className="fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden"
>
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<motion.div
<Link
key={item.name}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
href={item.href}
onClick={() => setIsOpen(false)}
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
>
<Link
href={item.href}
className="text-stone-600 hover:text-stone-900 transition-colors duration-200 font-medium relative group px-2 py-1 liquid-hover"
onClick={(e) => {
if (item.href.startsWith("#")) {
e.preventDefault();
const element = document.querySelector(item.href);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}
}}
>
{item.name}
<motion.span
className="absolute -bottom-1 left-0 right-0 h-0.5 bg-gradient-to-r from-liquid-mint to-liquid-lavender rounded-full"
initial={{ scaleX: 0, opacity: 0 }}
whileHover={{ scaleX: 1, opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
}}
style={{ transformOrigin: "left center" }}
/>
</Link>
</motion.div>
))}
</nav>
<div className="hidden md:flex items-center space-x-3">
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
<Link
href={enHref}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "en"
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-white/60"
}`}
aria-label="Switch language to English"
>
EN
{item.name}
</Link>
<Link
href={deHref}
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
locale === "de"
? "bg-stone-900 text-stone-50"
: "text-stone-700 hover:bg-white/60"
}`}
aria-label="Sprache auf Deutsch umstellen"
>
DE
</Link>
</div>
{socialLinks.map((social) => (
<motion.a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
whileHover={{ scale: 1.1, rotate: 5 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-full bg-white/40 hover:bg-white/80 border border-white/50 text-stone-600 hover:text-stone-900 transition-all shadow-sm liquid-hover"
>
<social.icon size={18} />
</motion.a>
))}
</div>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover"
aria-label={isOpen ? "Close menu" : "Open menu"}
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>
</motion.div>
</div>
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-40 md:hidden pointer-events-auto"
onClick={() => setIsOpen(false)}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -20 }}
transition={{ duration: 0.3, type: "spring" }}
className="absolute top-24 left-4 right-4 bg-cream/95 backdrop-blur-xl border border-stone-200 shadow-xl rounded-3xl z-50 p-6 pointer-events-auto"
>
<div className="space-y-2">
{navItems.map((item, index) => (
<motion.div
key={item.name}
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -20, opacity: 0 }}
transition={{ delay: index * 0.05 }}
>
<Link
href={item.href}
onClick={(e) => {
setIsOpen(false);
if (item.href.startsWith("#")) {
e.preventDefault();
setTimeout(() => {
const element = document.querySelector(item.href);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 100);
}
}}
className="block text-stone-600 hover:text-stone-900 hover:bg-white/50 transition-all font-medium py-3 px-4 rounded-xl"
>
{item.name}
</Link>
</motion.div>
))}
<div className="pt-6 mt-4 border-t border-stone-200">
<div className="flex justify-center space-x-4">
{socialLinks.map((social, index) => (
<motion.a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{
delay: (navItems.length + index) * 0.05,
}}
whileHover={{ scale: 1.1 }}
className="p-3 rounded-full bg-white/60 text-stone-600 shadow-sm"
aria-label={social.label}
>
<social.icon size={20} />
</motion.a>
))}
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</motion.header>
)}
</AnimatePresence>
</>
);
};

View File

@@ -0,0 +1,249 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations";
interface HeaderClientProps {
locale: string;
translations: NavTranslations;
}
export default function HeaderClient({ locale, translations }: HeaderClientProps) {
const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const pathname = usePathname();
const searchParams = useSearchParams();
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const navItems = [
{ name: translations.home, href: `/${locale}` },
{ name: translations.about, href: isHome ? "#about" : `/${locale}#about` },
{ name: translations.projects, href: isHome ? "#projects" : `/${locale}/projects` },
{ name: translations.contact, href: isHome ? "#contact" : `/${locale}#contact` },
];
const socialLinks = [
{ icon: SiGithub, href: "https://github.com/Denshooter", label: "GitHub" },
{
icon: SiLinkedin,
href: "https://linkedin.com/in/dkonkol",
label: "LinkedIn",
},
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
];
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
const qs = searchParams.toString();
const query = qs ? `?${qs}` : "";
const enHref = `/en${pathWithoutLocale}${query}`;
const deHref = `/de${pathWithoutLocale}${query}`;
return (
<>
<motion.header
initial={false}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
>
<div
className={`pointer-events-auto transition-all duration-500 ease-out ${
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
}`}
>
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={`backdrop-blur-xl transition-all duration-500 flex justify-between items-center ${
scrolled
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
}`}
>
<motion.div
whileHover={{ scale: 1.05 }}
className="flex items-center space-x-2"
>
<Link
href={`/${locale}`}
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
>
dk<span className="text-red-500">0</span>
</Link>
</motion.div>
<nav className="hidden md:flex items-center space-x-8">
{navItems.map((item) => (
<motion.div
key={item.name}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
>
<Link
href={item.href}
className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
>
{item.name}
</Link>
</motion.div>
))}
{/* Language Switcher */}
<div className="flex items-center space-x-2 ml-4 pl-4 border-l border-stone-300">
<Link
href={enHref}
className={`px-2 py-1 text-sm font-medium rounded transition-all ${
locale === "en"
? "bg-stone-900 text-white"
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
}`}
>
EN
</Link>
<Link
href={deHref}
className={`px-2 py-1 text-sm font-medium rounded transition-all ${
locale === "de"
? "bg-stone-900 text-white"
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
}`}
>
DE
</Link>
</div>
</nav>
<motion.button
whileHover={{ scale: 1.05, rotate: 90 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors"
aria-label="Toggle menu"
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>
</motion.div>
</div>
</motion.header>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
onClick={() => setIsOpen(false)}
/>
)}
</AnimatePresence>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: "100%", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100%", opacity: 0 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto"
>
<div className="p-6">
<div className="flex justify-between items-center mb-8">
<Link
href={`/${locale}`}
className="text-2xl font-black text-stone-900"
onClick={() => setIsOpen(false)}
>
dk<span className="text-red-500">0</span>
</Link>
<button
onClick={() => setIsOpen(false)}
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
aria-label="Close menu"
>
<X size={24} />
</button>
</div>
<nav className="space-y-2">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-3 text-stone-700 hover:bg-stone-50 rounded-lg font-medium transition-colors"
>
{item.name}
</Link>
))}
</nav>
{/* Language Switcher Mobile */}
<div className="flex gap-2 mt-6 pt-6 border-t border-stone-200">
<Link
href={enHref}
onClick={() => setIsOpen(false)}
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
locale === "en"
? "bg-stone-900 text-white"
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
}`}
>
EN
</Link>
<Link
href={deHref}
onClick={() => setIsOpen(false)}
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
locale === "de"
? "bg-stone-900 text-white"
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
}`}
>
DE
</Link>
</div>
<div className="mt-8 pt-6 border-t border-stone-200">
<div className="flex justify-center space-x-6">
{socialLinks.map((link) => {
const Icon = link.icon;
return (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-all"
aria-label={link.label}
>
<Icon size={20} />
</a>
);
})}
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -1,251 +1,133 @@
"use client";
import { motion } from "framer-motion";
import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import Image from "next/image";
import { useEffect, useState } from "react";
const Hero = () => {
const locale = useLocale();
const t = useTranslations("home.hero");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await fetch(
`/api/content/page?key=${encodeURIComponent("home-hero")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
// If the API falls back to another locale, keep showing next-intl strings
// so the locale switch visibly changes the page.
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
} else {
setCmsDoc(null);
const res = await fetch(`/api/messages?locale=${locale}`);
if (res.ok) {
const data = await res.json();
setCmsMessages(data.messages || {});
}
} catch {
// ignore; fallback to static
setCmsDoc(null);
}
} catch {}
})();
}, [locale]);
const features = [
{ icon: Code, text: t("features.f1") },
{ icon: Zap, text: t("features.f2") },
{ icon: Rocket, text: t("features.f3") },
];
// Helper to get CMS text or fallback
const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
return (
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10">
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
{/* Profile Image with Organic Blob Mask */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }}
className="mb-12 flex justify-center relative z-20"
>
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
<motion.div
className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10"
animate={{
borderRadius: [
"60% 40% 30% 70%/60% 30% 70% 40%",
"30% 60% 70% 40%/50% 60% 30% 60%",
"60% 40% 30% 70%/60% 30% 70% 40%",
],
rotate: [0, 120, 0],
scale: [1, 1.08, 1],
}}
transition={{
duration: 35,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/>
<motion.div
className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10"
animate={{
borderRadius: [
"40% 60% 70% 30%/40% 50% 60% 50%",
"60% 30% 40% 70%/60% 40% 70% 30%",
"40% 60% 70% 30%/40% 50% 60% 50%",
],
rotate: [0, -90, 0],
scale: [1, 1.05, 1],
}}
transition={{
duration: 40,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/>
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
{/* Liquid Ambient Background */}
<div className="absolute inset-0 pointer-events-none">
<motion.div
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }}
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
/>
<motion.div
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
/>
</div>
{/* The Image Container with Organic Border Radius */}
<motion.div
className="absolute inset-0 overflow-hidden bg-stone-100"
style={{
filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))",
willChange: "border-radius",
}}
animate={{
borderRadius: [
"60% 40% 30% 70%/60% 30% 70% 40%",
"30% 60% 70% 40%/50% 60% 30% 60%",
"60% 40% 30% 70%/60% 30% 70% 40%",
],
}}
transition={{
duration: 12,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
>
{/* Use a plain <img> to fully bypass Next.js image optimizer (dev 400 issue). */}
<img
src="/images/me.jpg"
alt="Dennis Konkol"
className="absolute inset-0 w-full h-full object-cover scale-105 hover:scale-[1.08] transition-transform duration-1000 ease-out"
loading="eager"
decoding="async"
/>
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20">
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24">
{/* Glossy Overlay for Liquid Feel */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/25 via-transparent to-white/10 opacity-60 pointer-events-none z-10" />
{/* Inner Border/Highlight */}
<div className="absolute inset-0 border-[2px] border-white/30 rounded-[inherit] pointer-events-none z-20" />
</motion.div>
{/* Domain Badge - repositioned below image */}
{/* Left: Text Content */}
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
transition={{ duration: 0.5 }}
className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
>
<div className="px-6 py-2.5 rounded-full glass-panel text-stone-800 font-sans font-bold text-sm tracking-wide shadow-lg backdrop-blur-xl border border-white/50">
dk<span className="text-red-500 font-extrabold">0</span>.dev
</div>
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
</motion.div>
{/* Floating Badges - subtle animations */}
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
<motion.span
initial={{ x: -50 }}
animate={{ x: 0 }}
transition={{ duration: 0.8, delay: 0.1 }}
className="block"
>
{getLabel("hero.line1", "Building")}
</motion.span>
<motion.span
initial={{ x: -50 }}
animate={{ x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4"
>
{getLabel("hero.line2", "Stuff.")}
</motion.span>
</h1>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight">
{t("description")}
</p>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: 5 }}
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6 }}
className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4"
>
<Code size={24} />
</motion.div>
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
whileHover={{ scale: 1.1, rotate: -5 }}
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
>
<Zap size={24} />
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
{t("ctaWork")}
</a>
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors">
{t("ctaContact")}
</a>
</motion.div>
</div>
</motion.div>
{/* Main Title */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
className="mb-8 flex flex-col items-center justify-center relative"
>
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2">
Dennis Konkol
</h1>
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 mt-2">
Software Engineer
</h2>
</motion.div>
{/* Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
) : (
<p>{t("description")}</p>
)}
</motion.div>
{/* Features */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-wrap justify-center gap-4 mb-12"
>
{features.map((feature, index) => (
<motion.div
key={feature.text}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.5,
delay: 0.5 + index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{ scale: 1.03, y: -3 }}
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/70 border border-white/90 shadow-sm backdrop-blur-sm"
>
<feature.icon className="w-4 h-4 text-stone-700" />
<span className="text-stone-700 font-medium text-sm">
{feature.text}
</span>
</motion.div>
))}
</motion.div>
{/* CTA Buttons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
className="flex flex-col sm:flex-row gap-5 justify-center items-center"
>
<motion.a
href="#projects"
whileHover={{ scale: 1.03, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="px-8 py-4 bg-stone-900 text-cream rounded-full shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2"
{/* Right: The Photo */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1
}}
transition={{
opacity: { duration: 1 },
scale: { duration: 1 }
}}
className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0"
>
<span className="text-cream">{t("ctaWork")}</span>
<ArrowDown size={18} />
</motion.a>
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1024px) 320px, 500px" />
</div>
<motion.a
href="#contact"
whileHover={{ scale: 1.03, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all duration-500"
>
<span>{t("ctaContact")}</span>
</motion.a>
</motion.div>
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
</div>
</motion.div>
</div>
</div>
<motion.div
animate={{ y: [0, 15, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4"
>
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
</motion.div>
</section>
);
};

View File

@@ -1,34 +1,12 @@
"use client";
import { useState, useEffect } from "react";
import { motion, Variants } from "framer-motion";
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react";
import { motion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useLocale, useTranslations } from "next-intl";
const fadeInUp: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
const staggerContainer: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
delayChildren: 0.1,
},
},
};
import { Skeleton } from "./ui/Skeleton";
interface Project {
id: number;
@@ -47,215 +25,105 @@ interface Project {
const Projects = () => {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const locale = useLocale();
const t = useTranslations("home.projects");
useTranslations("home.projects");
useEffect(() => {
const loadProjects = async () => {
try {
const response = await fetch(
"/api/projects?featured=true&published=true&limit=6",
);
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
if (response.ok) {
const data = await response.json();
setProjects(data.projects || []);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error loading projects:", error);
}
} catch (error) {
console.error("Featured projects fetch failed:", error);
} finally {
setLoading(false);
}
};
loadProjects();
}, []);
return (
<section
id="projects"
className="py-24 px-4 relative bg-gradient-to-br from-liquid-peach/15 via-liquid-yellow/10 to-liquid-coral/15 overflow-hidden"
>
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
<div className="max-w-7xl mx-auto">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
variants={fadeInUp}
className="text-center mb-20"
>
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
{t("title")}
</h2>
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light">
{t("subtitle")}
</p>
</motion.div>
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
<div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
Projects that pushed my boundaries.
</p>
</div>
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all text-xs uppercase tracking-widest">
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
</Link>
</div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-50px" }}
variants={staggerContainer}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
{projects.map((project) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
{loading ? (
Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-6">
<Skeleton className="aspect-[4/3] rounded-[2.5rem]" />
<div className="space-y-3">
<Skeleton className="h-8 w-1/2" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
))
) : (
projects.map((project) => (
<motion.div
key={project.id}
variants={fadeInUp}
whileHover={{ y: -8 }}
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-[box-shadow,border-color,background-color] duration-500"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="group relative"
>
{/* Project Cover / Image Area */}
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? (
<>
<Link href={`/${locale}/projects/${project.slug}`} className="block">
{/* Image Card */}
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
{project.imageUrl ? (
<Image
src={project.imageUrl}
alt={project.title}
fill
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
className="object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
</>
) : (
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
<div className="relative z-10">
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
{project.title.charAt(0)}
) : (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
<span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
</div>
)}
{/* Overlay on Hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
</div>
{/* Text Content */}
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
{project.title}
</h3>
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
{project.description}
</p>
</div>
<div className="hidden sm:flex gap-2">
{project.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
{tag}
</span>
</div>
</div>
)}
{/* Texture/Grain Overlay */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{/* Animated Shine Effect */}
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
{/* Featured Badge */}
{project.featured && (
<div className="absolute top-3 left-3 z-20">
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
{t("featured")}
</div>
</div>
)}
{/* Overlay Links */}
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="GitHub"
onClick={(e) => e.stopPropagation()}
>
<Github size={20} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
aria-label="Live Demo"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={20} />
</a>
)}
</div>
</div>
{/* Content */}
<div className="p-6 flex flex-col flex-1">
{/* Stretched Link covering the whole card (including image area) */}
<Link
href={`/${locale}/projects/${project.slug}`}
className="absolute inset-0 z-10"
aria-label={`View project ${project.title}`}
/>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
{project.title}
</h3>
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
<Calendar size={12} />
<span>{new Date(project.date).getFullYear()}</span>
))}
</div>
</div>
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
{project.description}
</p>
<div className="flex flex-wrap gap-2 mb-6">
{project.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
>
{tag}
</span>
))}
{project.tags.length > 4 && (
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
)}
</div>
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
<div className="flex gap-3">
{project.github && (
<a
href={project.github}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<Github size={18} />
</a>
)}
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
<a
href={project.live}
target="_blank"
rel="noopener noreferrer"
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink size={18} />
</a>
)}
</div>
</div>
</div>
</Link>
</motion.div>
))}
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="mt-16 text-center"
>
<Link
href={`/${locale}/projects`}
className="inline-flex items-center gap-2 px-8 py-4 bg-white border border-stone-200 rounded-full text-stone-700 font-medium hover:bg-stone-50 hover:border-stone-300 hover:gap-3 transition-all duration-500 ease-out shadow-sm hover:shadow-md"
>
{t("viewAll")} <ArrowRight size={16} />
</Link>
</motion.div>
)))}
</div>
</div>
</section>
);

View File

@@ -0,0 +1,241 @@
"use client";
import { motion } from "framer-motion";
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface BookReview {
id: string;
hardcover_id?: string;
book_title: string;
book_author: string;
book_image?: string;
rating?: number | null;
review?: string | null;
finished_at?: string;
}
const StarRating = ({ rating }: { rating: number }) => {
return (
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
size={14}
className={
star <= rating
? "text-amber-500 fill-amber-500"
: "text-stone-300 dark:text-stone-600"
}
/>
))}
</div>
);
};
const stripHtml = (html: string) => {
if (typeof window === 'undefined') return html; // Fallback for SSR
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || "";
};
const ReadBooks = () => {
const locale = useLocale();
const t = useTranslations("home.about.readBooks");
const [reviews, setReviews] = useState<BookReview[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const INITIAL_SHOW = 3;
useEffect(() => {
const fetchReviews = async () => {
try {
const res = await fetch(
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
{ cache: "default" }
);
if (!res.ok) {
throw new Error("Failed to fetch");
}
const data = await res.json();
if (data.bookReviews) {
setReviews(data.bookReviews);
} else {
setReviews([]);
}
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("Error fetching book reviews:", error);
}
setReviews([]);
} finally {
setLoading(false);
}
};
fetchReviews();
}, [locale]);
if (loading) {
return (
<div className="space-y-6">
{[1, 2].map((i) => (
<div key={i} className="flex flex-col sm:flex-row gap-4 items-start">
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg shrink-0" />
<div className="flex-1 space-y-2 w-full">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4 pt-2" />
<Skeleton className="h-12 w-full pt-2" />
</div>
</div>
))}
</div>
);
}
if (reviews.length === 0) {
return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen
}
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
const hasMore = reviews.length > INITIAL_SHOW;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} ({reviews.length})
</h3>
</div>
{/* Book Reviews */}
{visibleReviews.map((review, index) => (
<motion.div
key={review.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.6,
delay: index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{
scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
>
{/* Background Blob */}
<motion.div
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
animate={{
scale: [1, 1.15, 1],
opacity: [0.3, 0.45, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.5,
}}
/>
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
{/* Book Cover */}
{review.book_image && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0"
>
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
<Image
src={review.book_image}
alt={review.book_title}
fill
className="object-cover"
sizes="(max-width: 640px) 80px, 96px"
/>
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
</div>
</motion.div>
)}
{/* Book Info */}
<div className="flex-1 min-w-0">
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
{review.book_title}
</h4>
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
{review.book_author}
</p>
{/* Rating (Optional) */}
{review.rating && review.rating > 0 && (
<div className="flex items-center gap-2 mb-2">
<StarRating rating={review.rating} />
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
{review.rating}/5
</span>
</div>
)}
{/* Review Text (Optional) */}
{review.review && (
<p className="text-sm text-stone-700 dark:text-stone-300 leading-relaxed line-clamp-3 italic">
&ldquo;{stripHtml(review.review)}&rdquo;
</p>
)}
{/* Finished Date */}
{review.finished_at && (
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
{t("finishedAt")}{" "}
{new Date(review.finished_at).toLocaleDateString(
locale === "de" ? "de-DE" : "en-US",
{ year: "numeric", month: "short" }
)}
</p>
)}
</div>
</div>
</motion.div>
))}
{/* Show More / Show Less */}
{hasMore && (
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
>
{expanded ? (
<>
{t("showLess")} <ChevronUp size={16} />
</>
) : (
<>
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
<ChevronDown size={16} />
</>
)}
</motion.button>
)}
</div>
);
};
export default ReadBooks;

View File

@@ -2,13 +2,6 @@
import React, { useEffect, useState } from "react";
// Lazy load providers to avoid webpack module resolution issues
const AnalyticsProvider = React.lazy(() =>
import("@/components/AnalyticsProvider").then((mod) => ({
default: mod.AnalyticsProvider,
}))
);
const ToastProvider = React.lazy(() =>
import("@/components/Toast").then((mod) => ({
default: mod.ToastProvider,
@@ -38,14 +31,11 @@ export default function RootProviders({
return (
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
<AnalyticsProvider>
<ToastProvider>
<BackgroundBlobs />
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</AnalyticsProvider>
<ToastProvider>
<BackgroundBlobs />
<div className="relative z-10">{children}</div>
<ChatWidget />
</ToastProvider>
</React.Suspense>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import React from "react";
import { ShaderGradientCanvas, ShaderGradient } from "@shadergradient/react";
const ShaderGradientBackground = () => {
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: -1,
filter: "blur(150px)",
opacity: 0.65,
pointerEvents: "none",
}}
>
<ShaderGradientCanvas
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
>
{/* Sphere 1 - Links oben */}
<ShaderGradient
control="props"
type="sphere"
animate="off"
brightness={1.3}
cAzimuthAngle={180}
cDistance={3.6}
cPolarAngle={90}
cameraZoom={1}
color1="#b01040"
color2="#b04a17"
color3="#e167c5"
positionX={-2.5}
positionY={1.5}
positionZ={0}
rotationX={0}
rotationY={15}
rotationZ={50}
uAmplitude={6.0}
uDensity={0.8}
uFrequency={5.5}
uSpeed={0.5}
uStrength={5.0}
/>
{/* Sphere 2 - Rechts mitte */}
<ShaderGradient
control="props"
type="sphere"
animate="off"
brightness={1.25}
cAzimuthAngle={180}
cDistance={3.6}
cPolarAngle={90}
cameraZoom={1}
color1="#e167c5"
color2="#b01040"
color3="#b04a17"
positionX={2.0}
positionY={-0.5}
positionZ={-0.5}
rotationX={0}
rotationY={25}
rotationZ={70}
uAmplitude={5.5}
uDensity={0.9}
uFrequency={4.8}
uSpeed={0.45}
uStrength={4.8}
/>
{/* Sphere 3 - Unten links */}
<ShaderGradient
control="props"
type="sphere"
animate="off"
brightness={1.2}
cAzimuthAngle={180}
cDistance={3.6}
cPolarAngle={90}
cameraZoom={1}
color1="#b04a17"
color2="#e167c5"
color3="#b01040"
positionX={-0.5}
positionY={-2.0}
positionZ={0.3}
rotationX={0}
rotationY={20}
rotationZ={60}
uAmplitude={5.8}
uDensity={0.7}
uFrequency={6.0}
uSpeed={0.52}
uStrength={4.9}
/>
</ShaderGradientCanvas>
</div>
);
};
export default ShaderGradientBackground;

View File

@@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { motion } from "framer-motion";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div className="w-9 h-9" />;
}
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={18} className="text-amber-400" />
) : (
<Moon size={18} className="text-stone-600" />
)}
</motion.button>
);
}

View File

@@ -0,0 +1,60 @@
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
export const BentoGrid = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
className
)}
>
{children}
</div>
);
};
export const BentoGridItem = ({
className,
title,
description,
header,
icon,
onClick,
}: {
className?: string;
title?: string | React.ReactNode;
description?: string | React.ReactNode;
header?: React.ReactNode;
icon?: React.ReactNode;
onClick?: () => void;
}) => {
return (
<motion.div
whileHover={{ y: -5 }}
transition={{ duration: 0.2 }}
className={cn(
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
className
)}
onClick={onClick}
>
{header}
<div className="group-hover/bento:translate-x-2 transition duration-200">
{icon}
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
{title}
</div>
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
{description}
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-stone-200/50 dark:bg-stone-800/50",
className
)}
{...props}
/>
);
}

View File

@@ -60,7 +60,7 @@ function EditorPageContent() {
const [isSaving, setIsSaving] = useState(false);
const [isCreating, setIsCreating] = useState(!projectId);
const [editLocale, setEditLocale] = useState(initialLocale);
const [baseTexts, setBaseTexts] = useState<{ title: string; description: string } | null>(null);
const [baseTexts, setBaseTexts] = useState<{ title: string; description: string; content: string } | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [_isTyping, setIsTyping] = useState(false);
const [history, setHistory] = useState<typeof formData[]>([]);
@@ -96,6 +96,7 @@ function EditorPageContent() {
setBaseTexts({
title: foundProject.title || "",
description: foundProject.description || "",
content: foundProject.content || "",
});
const initialData = {
title: foundProject.title || "",
@@ -145,19 +146,64 @@ function EditorPageContent() {
});
if (!response.ok) return;
const data = await response.json();
const tr = data.translation as { title?: string; description?: string } | null;
if (tr?.title && tr?.description) {
setFormData((prev) => ({
...prev,
title: tr.title || prev.title,
description: tr.description || prev.description,
}));
const tr = data.translation as { title?: string; description?: string; content?: unknown } | null;
const translatedContent = (() => {
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 null;
})();
if (tr?.title || tr?.description || translatedContent !== null) {
setFormData((prev) => {
const next = {
...prev,
title: tr?.title || prev.title,
description: tr?.description || prev.description,
content: translatedContent ?? prev.content,
};
return next;
});
if (translatedContent !== null) {
shouldUpdateContentRef.current = true;
}
}
} catch {
// ignore translation load failures
}
}, []);
const switchLocale = useCallback(
(next: string) => {
setEditLocale(next);
if (projectId) {
const newUrl = `/editor?id=${projectId}&locale=${encodeURIComponent(next)}`;
window.history.replaceState({}, "", newUrl);
}
if (next === "en" && baseTexts) {
setFormData((prev) => {
const nextData = {
...prev,
title: baseTexts.title,
description: baseTexts.description,
content: baseTexts.content,
};
return nextData;
});
shouldUpdateContentRef.current = true;
return;
}
if (projectId) {
loadTranslation(projectId, next);
}
},
[projectId, baseTexts, loadTranslation],
);
// Check authentication and load project
useEffect(() => {
const init = async () => {
@@ -188,6 +234,7 @@ function EditorPageContent() {
live: "",
image: "",
};
setBaseTexts({ title: "", description: "", content: "" });
setFormData(initialData);
setOriginalFormData(initialData);
setHistory([initialData]);
@@ -240,11 +287,12 @@ function EditorPageContent() {
const saveTitle = editLocale === "en" ? formData.title.trim() : (baseTexts?.title || formData.title.trim());
const saveDescription =
editLocale === "en" ? formData.description.trim() : (baseTexts?.description || formData.description.trim());
const saveContent = editLocale === "en" ? formData.content.trim() : (baseTexts?.content || formData.content.trim());
const saveData = {
title: saveTitle,
description: saveDescription,
content: formData.content.trim(),
content: saveContent,
category: formData.category,
tags: formData.tags,
github: formData.github.trim() || null,
@@ -302,12 +350,21 @@ function EditorPageContent() {
locale: editLocale,
title: formData.title.trim(),
description: formData.description.trim(),
content: formData.content.trim(),
}),
});
} catch {
// ignore translation save failures
}
}
if (editLocale === "en") {
setBaseTexts({
title: savedProject.title || "",
description: savedProject.description || "",
content: savedProject.content || "",
});
}
// Update project ID if it was a new project
if (!projectId && savedProject.id) {
@@ -706,27 +763,40 @@ function EditorPageContent() {
<label className="block text-sm font-medium text-stone-300 mb-2">
Language
</label>
<div className="custom-select">
<select
value={editLocale}
onChange={(e) => {
const next = e.target.value;
setEditLocale(next);
if (projectId) {
// Update URL for deep-linking and reload translation
const newUrl = `/editor?id=${projectId}&locale=${encodeURIComponent(next)}`;
window.history.replaceState({}, "", newUrl);
loadTranslation(projectId, next);
}
}}
>
<option value="en">English (default)</option>
<option value="de">Deutsch</option>
</select>
<div className="flex items-center gap-2">
<div className="custom-select">
<select
value={editLocale}
onChange={(e) => switchLocale(e.target.value)}
>
<option value="en">English (default)</option>
<option value="de">Deutsch</option>
</select>
</div>
<div className="inline-flex rounded-lg overflow-hidden border border-stone-700/40">
<button
type="button"
onClick={() => switchLocale("en")}
className={`px-3 py-1 text-sm ${
editLocale === "en" ? "bg-stone-700 text-white" : "bg-stone-800 text-stone-300 hover:bg-stone-700"
}`}
>
EN
</button>
<button
type="button"
onClick={() => switchLocale("de")}
className={`px-3 py-1 text-sm ${
editLocale === "de" ? "bg-stone-700 text-white" : "bg-stone-800 text-stone-300 hover:bg-stone-700"
}`}
>
DE
</button>
</div>
</div>
{editLocale !== "en" && (
<p className="text-xs text-stone-400 mt-2">
Title/description are saved as a translation. Other fields are global.
Title, description, and content are saved as a translation. Other fields are global.
</p>
)}
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
@@ -10,6 +11,9 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
// Capture exception in Sentry
Sentry.captureException(error);
// Log error details to console
console.error("Global Error:", error);
console.error("Error Name:", error.name);

View File

@@ -2,34 +2,55 @@
@tailwind components;
@tailwind utilities;
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap");
:root {
/* Organic Modern Palette */
--background: #fdfcf8; /* Cream */
--foreground: #292524; /* Warm Grey */
--card: rgba(255, 255, 255, 0.6);
--card-foreground: #292524;
--popover: #ffffff;
--popover-foreground: #292524;
--primary: #292524;
--primary-foreground: #fdfcf8;
--secondary: #e7e5e4;
--secondary-foreground: #292524;
--muted: #f5f5f4;
--muted-foreground: #78716c;
--accent: #f3f1e7; /* Sand */
--accent-foreground: #292524;
--destructive: #ef4444;
--destructive-foreground: #fdfcf8;
--border: #e7e5e4;
--input: #e7e5e4;
--ring: #a7f3d0; /* Mint ring */
/* Warm Brown & Off-White Palette */
--background: #faf8f3; /* Warm off-white */
--foreground: #3e2723; /* Rich brown */
--card: rgba(255, 252, 245, 0.7);
--card-foreground: #3e2723;
--popover: #fffcf5;
--popover-foreground: #3e2723;
--primary: #5d4037; /* Medium brown */
--primary-foreground: #faf8f3;
--secondary: #d7ccc8; /* Light taupe */
--secondary-foreground: #3e2723;
--muted: #efebe9; /* Very light brown */
--muted-foreground: #795548; /* Muted brown */
--accent: #bcaaa4; /* Warm taupe */
--accent-foreground: #3e2723;
--destructive: #d84315; /* Warm red-brown */
--destructive-foreground: #faf8f3;
--border: #d7ccc8;
--input: #efebe9;
--ring: #a1887f; /* Warm brown ring */
--radius: 1rem;
}
.dark {
--background: #1c1917; /* stone-900 */
--foreground: #f5f5f4; /* stone-100 */
--card: rgba(28, 25, 23, 0.7);
--card-foreground: #f5f5f4;
--popover: #1c1917;
--popover-foreground: #f5f5f4;
--primary: #d6d3d1; /* stone-300 */
--primary-foreground: #1c1917;
--secondary: #44403c; /* stone-700 */
--secondary-foreground: #f5f5f4;
--muted: #292524; /* stone-800 */
--muted-foreground: #a8a29e; /* stone-400 */
--accent: #57534e; /* stone-600 */
--accent-foreground: #f5f5f4;
--destructive: #7f1d1d; /* dark red */
--destructive-foreground: #f5f5f4;
--border: #44403c;
--input: #292524;
--ring: #d6d3d1;
}
body {
background-color: var(--background);
background: var(--background);
color: var(--foreground);
font-family: "Inter", sans-serif;
margin: 0;
@@ -38,12 +59,14 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
position: relative;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Custom Selection */
::selection {
background: #a7f3d0; /* Mint */
color: #292524;
background: var(--primary); /* Rich brown for better contrast */
color: var(--primary-foreground); /* Off-white */
}
/* Smooth Scrolling */
@@ -53,35 +76,33 @@ html {
/* Liquid Glass Effects */
.glass-panel {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(12px) saturate(120%);
-webkit-backdrop-filter: blur(12px) saturate(120%);
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
background: var(--card);
backdrop-filter: blur(20px) saturate(130%);
-webkit-backdrop-filter: blur(20px) saturate(130%);
border: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
will-change: backdrop-filter;
}
.glass-card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.85);
background: var(--card);
backdrop-filter: blur(30px) saturate(200%);
-webkit-backdrop-filter: blur(30px) saturate(200%);
border: 1px solid var(--border);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.03),
0 2px 4px -1px rgba(0, 0, 0, 0.02),
inset 0 0 20px rgba(255, 255, 255, 0.5);
0 4px 6px -1px rgba(0, 0, 0, 0.06),
0 2px 4px -1px rgba(0, 0, 0, 0.05);
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
will-change: transform, box-shadow;
}
.glass-card:hover {
background: rgba(255, 255, 255, 0.8);
background: var(--card);
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.08),
0 10px 10px -5px rgba(0, 0, 0, 0.02),
inset 0 0 20px rgba(255, 255, 255, 0.8);
0 20px 25px -5px rgba(0, 0, 0, 0.15),
0 10px 10px -5px rgba(0, 0, 0, 0.08);
transform: translateY(-4px);
border-color: #ffffff;
border-color: var(--ring);
}
/* Typography & Headings */
@@ -91,16 +112,17 @@ h3,
h4,
h5,
h6 {
font-family: var(--font-playfair), Georgia, serif;
letter-spacing: -0.02em;
font-weight: 700;
color: #292524;
color: var(--foreground);
}
/* Improve text contrast */
/* Improve text contrast - using foreground variable for WCAG AA compliance */
p,
span,
div {
color: #44403c;
color: var(--foreground); /* #3e2723 - meets WCAG AA standards */
}
/* Hide scrollbar but keep functionality */
@@ -111,11 +133,11 @@ div {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d6d3d1;
background: #bcaaa4;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a29e;
background: #a1887f;
}
.scrollbar-hide::-webkit-scrollbar {
@@ -153,30 +175,40 @@ div {
/* Markdown Specifics for Blog/Projects */
.markdown h1 {
@apply text-4xl font-bold mb-6 text-stone-900 tracking-tight;
@apply text-4xl font-bold mb-6 tracking-tight;
color: var(--foreground);
}
.markdown h2 {
@apply text-2xl font-semibold mt-8 mb-4 text-stone-900 tracking-tight;
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
color: var(--foreground);
}
.markdown p {
@apply mb-4 leading-relaxed text-stone-700;
@apply mb-4 leading-relaxed;
color: var(--foreground);
}
.markdown a {
@apply text-stone-900 underline decoration-liquid-mint decoration-2 underline-offset-2 hover:text-black transition-colors duration-300;
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
color: var(--primary);
text-decoration-color: var(--accent);
}
.markdown ul {
@apply list-disc list-inside mb-4 space-y-2 text-stone-700;
@apply list-disc list-inside mb-4 space-y-2;
color: var(--foreground);
}
.markdown code {
@apply bg-stone-100 px-1.5 py-0.5 rounded text-sm text-stone-900 font-mono;
@apply px-1.5 py-0.5 rounded text-sm font-mono;
background: var(--muted);
color: var(--foreground);
}
.markdown pre {
@apply bg-stone-900 text-stone-50 p-4 rounded-xl overflow-x-auto mb-6;
@apply p-4 rounded-xl overflow-x-auto mb-6;
background: var(--foreground);
color: var(--background);
}
/* Admin Dashboard Styles - Organic Modern */
/* Admin Dashboard Styles - Warm Brown Theme */
.animated-bg {
background: #fdfcf8;
background: #faf8f3;
position: fixed;
top: 0;
left: 0;
@@ -186,30 +218,30 @@ div {
}
.admin-glass {
background: rgba(253, 252, 248, 0.9);
background: rgba(250, 248, 243, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid #e7e5e4;
color: #292524;
border-bottom: 1px solid #d7ccc8;
color: #3e2723;
}
.admin-glass-light {
background: #ffffff;
border: 1px solid #e7e5e4;
color: #292524;
background: #fffcf5;
border: 1px solid #d7ccc8;
color: #3e2723;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
box-shadow: 0 1px 2px rgba(62, 39, 35, 0.05);
}
.admin-glass-light:hover {
background: #fdfcf8;
border-color: #d6d3d1;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
background: #faf8f3;
border-color: #bcaaa4;
box-shadow: 0 4px 6px rgba(62, 39, 35, 0.08);
}
.admin-glass-card {
background: #ffffff;
border: 1px solid #e7e5e4;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
color: #292524;
background: #fffcf5;
border: 1px solid #d7ccc8;
box-shadow: 0 4px 6px -1px rgba(62, 39, 35, 0.06);
color: #3e2723;
}

View File

@@ -1,6 +1,6 @@
import "./globals.css";
import { Metadata } from "next";
import { Inter } from "next/font/google";
import { Inter, Playfair_Display } from "next/font/google";
import React from "react";
import ClientProviders from "./components/ClientProviders";
import { cookies } from "next/headers";
@@ -9,6 +9,15 @@ import { getBaseUrl } from "@/lib/seo";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
adjustFontFallback: true,
});
const playfair = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
display: "swap",
adjustFontFallback: true,
});
export default async function RootLayout({
@@ -19,11 +28,12 @@ export default async function RootLayout({
const cookieStore = await cookies();
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
return (
<html lang={locale}>
<html lang={locale} suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
</head>
<body className={inter.variable} suppressHydrationWarning>
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" />
<ClientProviders>{children}</ClientProviders>
</body>
</html>
@@ -32,11 +42,39 @@ export default async function RootLayout({
export const metadata: Metadata = {
metadataBase: new URL(getBaseUrl()),
title: "Dennis Konkol | Portfolio",
title: {
default: "Dennis Konkol | Portfolio",
template: "%s | Dennis Konkol",
},
description:
"Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.",
keywords: ["Dennis Konkol", "Software Engineer", "Portfolio", "Student"],
keywords: [
"Dennis Konkol",
"Software Engineer",
"Portfolio",
"Student",
"Web Development",
"Full Stack Developer",
"Osnabrück",
"Germany",
"React",
"Next.js",
"TypeScript",
],
authors: [{ name: "Dennis Konkol", url: "https://dk0.dev" }],
creator: "Dennis Konkol",
publisher: "Dennis Konkol",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
openGraph: {
title: "Dennis Konkol | Portfolio",
description:
@@ -51,6 +89,7 @@ export const metadata: Metadata = {
alt: "Dennis Konkol Portfolio",
},
],
locale: "en_US",
type: "website",
},
twitter: {
@@ -58,5 +97,12 @@ export const metadata: Metadata = {
title: "Dennis Konkol | Portfolio",
description: "Student & Software Engineer based in Osnabrück, Germany.",
images: ["https://dk0.dev/api/og"],
creator: "@denshooter",
},
verification: {
google: process.env.NEXT_PUBLIC_GOOGLE_VERIFICATION,
},
alternates: {
canonical: "https://dk0.dev",
},
};

View File

@@ -2,7 +2,7 @@
import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
@@ -15,7 +15,6 @@ export default function LegalNotice() {
const locale = useLocale();
const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
useEffect(() => {
(async () => {
@@ -24,114 +23,120 @@ export default function LegalNotice() {
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
setCmsTitle((data.content.title as string | null) ?? null);
} else {
setCmsDoc(null);
setCmsTitle(null);
}
} catch {
// ignore; fallback to static content
setCmsDoc(null);
setCmsTitle(null);
}
} catch {}
})();
}, [locale]);
return (
<div className="min-h-screen animated-bg">
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<Header />
<main className="max-w-4xl mx-auto px-4 pt-32 pb-20">
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="mb-8"
className="mb-20"
>
<Link
href={`/${locale}`}
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
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} />
<span>{t("backToHome")}</span>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
</Link>
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
{cmsTitle || "Impressum"}
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Legal<span className="text-liquid-mint">.</span>
</h1>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="glass-card p-8 rounded-2xl space-y-6"
>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
) : (
<>
<div className="text-gray-300 leading-relaxed">
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Inhalte dieser Website</h2>
<div className="space-y-2 text-gray-300">
<p>
<strong>Name:</strong> Dennis Konkol
</p>
<p>
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
</p>
<p>
<strong>E-Mail:</strong>{" "}
<Link href="mailto:info@dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">
info@dk0.dev
</Link>
</p>
<p>
<strong>Website:</strong>{" "}
<Link href="https://www.dk0.dev" className="text-blue-400 hover:text-blue-300 transition-colors">
dk0.dev
</Link>
{/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Legal Content (Large Box) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
{cmsDoc ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} />
</div>
) : (
<div className="space-y-16">
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<Scale className="text-liquid-mint" size={28} /> Angaben gemäß § 5 TMG
</h2>
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-4">
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
<p>Auf dem Ziegenbrink 2B</p>
<p>49082 Osnabrück, Deutschland</p>
</div>
</section>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<ShieldCheck className="text-liquid-sky" size={28} /> Haftungsausschluss
</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.
</p>
</section>
</div>
)}
</motion.div>
{/* Sidebar Widgets */}
<div className="lg:col-span-4 space-y-8">
{/* Quick Contact Box */}
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Direct Contact</h3>
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
<Mail className="text-liquid-mint" size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Email</p>
<Link href="mailto:info@dk0.dev" className="font-bold hover:text-liquid-mint transition-colors">info@dk0.dev</Link>
</div>
</div>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
<MapPin className="text-liquid-sky" size={20} />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Location</p>
<p className="font-bold">Osnabrück, GER</p>
</div>
</div>
</div>
</div>
<div className="text-gray-300">
<h2 className="text-2xl font-semibold mb-4">Haftung für Links</h2>
<p className="leading-relaxed">
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser
Websites und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum
Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde
ich derartige Links umgehend entfernen.
</p>
{/* Meta Info Box */}
<div className="bg-liquid-purple/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-purple/20 dark:border-stone-800/60">
<div className="flex items-center gap-4 mb-6">
<Clock className="text-liquid-purple" size={20} />
<div>
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p>
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">February 15, 2025</p>
</div>
</div>
<p className="text-xs text-stone-500 leading-relaxed">
This legal notice applies to all contents on dk0.dev and related social media profiles.
</p>
</div>
<div className="text-gray-300">
<h2 className="text-2xl font-semibold mb-4">Urheberrecht</h2>
<p className="leading-relaxed">
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter
Urheberrechtsschutz. Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist
verboten.
</p>
</div>
<div className="text-gray-300">
<h2 className="text-2xl font-semibold mb-4">Gewährleistung</h2>
<p className="leading-relaxed">
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine
Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser
Website.
</p>
</div>
<div className="pt-4 border-t border-gray-700">
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
</div>
</>
)}
</motion.div>
</div>
</div>
</main>
<Footer />
</div>

View File

@@ -259,10 +259,10 @@ const AdminPage = () => {
// Loading state
if (authState.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8]">
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-stone-600" />
<p className="text-stone-500">Loading...</p>
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-[#795548]" />
<p className="text-[#5d4037]">Loading...</p>
</div>
</div>
);
@@ -271,13 +271,13 @@ const AdminPage = () => {
// Lockout state
if (authState.isLocked) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8]">
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
<div className="text-center">
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-red-500" />
<div className="w-16 h-16 bg-[#fecaca] rounded-2xl flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-[#d84315]" />
</div>
<h2 className="text-2xl font-bold text-stone-900 mb-2">Account Locked</h2>
<p className="text-stone-500">Too many failed attempts. Please try again in 15 minutes.</p>
<h2 className="text-2xl font-bold text-[#3e2723] mb-2">Account Locked</h2>
<p className="text-[#5d4037]">Too many failed attempts. Please try again in 15 minutes.</p>
<button
onClick={() => {
try {
@@ -287,7 +287,7 @@ const AdminPage = () => {
}
window.location.reload();
}}
className="mt-4 px-6 py-2 bg-stone-900 text-stone-50 rounded-xl hover:bg-stone-800 transition-colors"
className="mt-4 px-6 py-2 bg-[#5d4037] text-[#faf8f3] rounded-xl hover:bg-[#3e2723] transition-colors"
>
Try Again
</button>
@@ -299,20 +299,20 @@ const AdminPage = () => {
// Login form
if (authState.showLogin || !authState.isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#fdfcf8] z-0">
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#faf8f3] z-0">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md p-6"
>
<div className="bg-white/80 backdrop-blur-xl rounded-3xl p-8 border border-stone-200 shadow-2xl relative z-10">
<div className="bg-[#fffcf5] backdrop-blur-xl rounded-3xl p-8 border border-[#d7ccc8] shadow-2xl relative z-10">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[#f3f1e7] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-sm border border-stone-100">
<Lock className="w-6 h-6 text-stone-600" />
<div className="w-16 h-16 bg-[#efebe9] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-sm border border-[#d7ccc8]">
<Lock className="w-6 h-6 text-[#5d4037]" />
</div>
<h1 className="text-2xl font-bold text-stone-900 mb-2 tracking-tight">Admin Access</h1>
<p className="text-stone-500">Enter your password to continue</p>
<h1 className="text-2xl font-bold text-[#3e2723] mb-2 tracking-tight">Admin Access</h1>
<p className="text-[#5d4037]">Enter your password to continue</p>
</div>
<form onSubmit={handleLogin} className="space-y-5">
@@ -323,13 +323,13 @@ const AdminPage = () => {
value={authState.password}
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
placeholder="Enter password"
className="w-full px-4 py-3.5 bg-white border border-stone-200 rounded-xl text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all shadow-sm"
className="w-full px-4 py-3.5 bg-white border border-[#d7ccc8] rounded-xl text-[#3e2723] placeholder:text-[#a1887f] focus:outline-none focus:ring-2 focus:ring-[#bcaaa4] focus:border-[#5d4037] transition-all shadow-sm"
disabled={authState.isLoading}
/>
<button
type="button"
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-stone-400 hover:text-stone-600 p-1"
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[#a1887f] hover:text-[#5d4037] p-1"
>
{authState.showPassword ? '👁️' : '👁️‍🗨️'}
</button>
@@ -338,9 +338,9 @@ const AdminPage = () => {
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-red-500 text-sm font-medium flex items-center"
className="mt-2 text-[#d84315] text-sm font-medium flex items-center"
>
<span className="w-1.5 h-1.5 bg-red-500 rounded-full mr-2" />
<span className="w-1.5 h-1.5 bg-[#d84315] rounded-full mr-2" />
{authState.error}
</motion.p>
)}
@@ -349,15 +349,15 @@ const AdminPage = () => {
<button
type="submit"
disabled={authState.isLoading || !authState.password}
className="w-full bg-stone-900 text-stone-50 py-3.5 px-6 rounded-xl font-semibold text-lg hover:bg-stone-800 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:ring-offset-2 focus:ring-offset-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg flex items-center justify-center"
className="w-full bg-[#5d4037] text-[#faf8f3] py-3.5 px-6 rounded-xl font-semibold text-lg hover:bg-[#3e2723] focus:outline-none focus:ring-2 focus:ring-[#bcaaa4] focus:ring-offset-2 focus:ring-offset-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg flex items-center justify-center"
>
{authState.isLoading ? (
<div className="flex items-center justify-center space-x-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-stone-50">Authenticating...</span>
<span className="text-[#faf8f3]">Authenticating...</span>
</div>
) : (
<span className="text-stone-50">Sign In</span>
<span className="text-[#faf8f3]">Sign In</span>
)}
</button>
</form>

View File

@@ -1,89 +1,109 @@
"use client";
import { motion } from "framer-motion";
import { ArrowLeft, Search, Terminal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
// Dynamically import KernelPanic404Wrapper to avoid SSR issues
const KernelPanic404 = dynamic(() => import("./components/KernelPanic404Wrapper"), {
ssr: false,
loading: () => (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#020202",
color: "#33ff00",
fontFamily: "monospace"
}}>
<div>Loading terminal...</div>
</div>
),
});
export default function NotFound() {
const [mounted, setMounted] = useState(false);
const router = useRouter();
useEffect(() => {
setMounted(true);
}, []);
// In tests, avoid next/dynamic loadable timing and render a stable fallback
if (process.env.NODE_ENV === "test") {
return (
<div>
Oops! The page you&apos;re looking for doesn&apos;t exist.
</div>
);
}
if (!mounted) {
return (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
margin: 0,
padding: 0,
overflow: "hidden",
backgroundColor: "#020202",
zIndex: 9998
}}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
color: "#33ff00",
fontFamily: "monospace"
}}>
Loading terminal...
</div>
</div>
);
}
if (!mounted) return null;
return (
<div style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
margin: 0,
padding: 0,
overflow: "hidden",
backgroundColor: "#020202",
zIndex: 9998
}}>
<KernelPanic404 />
</div>
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-16 sm:py-20 md:py-24 px-4 sm:px-6 flex items-center justify-center transition-colors duration-500">
<div className="max-w-7xl mx-auto w-full">
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8 max-w-5xl mx-auto">
{/* Main Error Card */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[300px] sm:min-h-[400px]"
>
<div>
<div className="flex items-center gap-3 mb-6 sm:mb-8 md:mb-12">
<div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
404
</div>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
</div>
<h1 className="text-4xl sm:text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.85] mb-4 sm:mb-6 md:mb-8">
Page not <br/>Found<span className="text-liquid-mint">.</span>
</h1>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
The content you are looking for has been moved, deleted, or never existed.
</p>
</div>
<div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4">
<Link
href="/"
className="group relative px-6 sm:px-10 py-3 sm:py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
>
Return Home
</Link>
<button
onClick={() => router.back()}
className="px-6 sm:px-10 py-3 sm:py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
>
Go Back
</button>
</div>
</motion.div>
{/* Sidebar Cards */}
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6">
{/* Search/Explore Projects */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
>
<div className="relative z-10">
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
</div>
<Link
href="/projects"
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
>
View Projects <ArrowLeft className="rotate-180" size={14} />
</Link>
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
</motion.div>
{/* Visit the Lab */}
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between group"
>
<div className="relative z-10">
<Terminal className="text-liquid-purple mb-4 sm:mb-6" size={28} />
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
<p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
</div>
<Link
href="/snippets"
className="mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
Enter the Lab <ArrowLeft className="rotate-180" size={14} />
</Link>
</motion.div>
</div>
</div>
</div>
</main>
);
}

View File

@@ -2,7 +2,7 @@
import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft } from 'lucide-react';
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
@@ -15,7 +15,6 @@ export default function PrivacyPolicy() {
const locale = useLocale();
const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
useEffect(() => {
(async () => {
@@ -24,284 +23,125 @@ export default function PrivacyPolicy() {
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
setCmsTitle((data.content.title as string | null) ?? null);
} else {
setCmsDoc(null);
setCmsTitle(null);
}
} catch {
// ignore; fallback to static content
setCmsDoc(null);
setCmsTitle(null);
}
} catch {}
})();
}, [locale]);
return (
<div className="min-h-screen animated-bg">
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<Header />
<main className="max-w-4xl mx-auto px-4 pt-32 pb-20">
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="mb-8"
className="mb-20"
>
<motion.a
<Link
href={`/${locale}`}
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
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} />
<span>{t("backToHome")}</span>
</motion.a>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
</Link>
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
{cmsTitle || "Datenschutzerklärung"}
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Privacy<span className="text-liquid-purple">.</span>
</h1>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="glass-card p-8 rounded-2xl space-y-6 text-white"
>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
) : (
<>
<div className="text-gray-300 leading-relaxed">
<p>
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
</p>
{/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Privacy Text (Large) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
{cmsDoc ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} />
</div>
) : (
<div className="space-y-16">
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<Shield className="text-liquid-mint" size={28} /> Allgemeiner Überblick
</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Der Schutz Ihrer persönlichen Daten ist mir ein besonderes Anliegen. Ich verarbeite Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, TMG).
</p>
</section>
<div className="text-gray-300 leading-relaxed">
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Datenverarbeitung</h2>
<div className="space-y-2 text-gray-300">
<p>
<strong>Name:</strong> Dennis Konkol
</p>
<p>
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
</p>
<p>
<strong>E-Mail:</strong>{" "}
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dk0.dev">
info@dk0.dev
</Link>
</p>
<p>
<strong>Website:</strong>{" "}
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dk0.dev">
dk0.dev
</Link>
<section>
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<Database className="text-liquid-sky" size={28} /> Datenerfassung
</h2>
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Wenn Sie per Formular auf der Website oder per E-Mail Kontakt mit mir aufnehmen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage bei mir gespeichert.
</p>
</section>
</div>
)}
</motion.div>
{/* Quick Info Cards */}
<div className="lg:col-span-4 space-y-8">
{/* Core Values Box */}
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-purple">Principles</h3>
<div className="space-y-6">
<div className="flex items-start gap-4">
<Lock className="text-liquid-mint mt-1" size={18} />
<div>
<p className="font-bold">Encryption</p>
<p className="text-xs text-stone-500">SSL/TLS secured data transfer.</p>
</div>
</div>
<div className="flex items-start gap-4">
<Eye className="text-liquid-sky mt-1" size={18} />
<div>
<p className="font-bold">Transparency</p>
<p className="text-xs text-stone-500">No hidden tracking algorithms.</p>
</div>
</div>
<div className="flex items-start gap-4">
<Globe className="text-liquid-purple mt-1" size={18} />
<div>
<p className="font-bold">Compliance</p>
<p className="text-xs text-stone-500">GDPR / DSGVO optimized.</p>
</div>
</div>
<p className="mt-4">
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
Verantwortlichen.
</p>
</div>
</div>
<h2 className="text-2xl font-semibold mt-6">
Erfassung allgemeiner Informationen beim Besuch meiner Website
</h2>
<div className="mt-2">
Beim Zugriff auf meiner Website werden automatisch Informationen allgemeiner Natur erfasst. Diese
beinhalten unter anderem:
<ul className="list-disc list-inside mt-2">
<li>IP-Adresse (in anonymisierter Form)</li>
<li>Uhrzeit</li>
<li>Browsertyp</li>
<li>Verwendetes Betriebssystem</li>
<li>Referrer-URL (die zuvor besuchte Seite)</li>
</ul>
<br />
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
Rückschlüsse auf Ihre Person sind nicht möglich. Diese Daten werden verarbeitet, um:
<ul className="list-disc list-inside mt-2">
<li>die Inhalte meiner Website korrekt auszuliefern,</li>
<li>die Inhalte meiner Website zu optimieren,</li>
<li>die Systemsicherheit und -stabilität zu analysiern.</li>
</ul>
{/* Cookie Status Indicator */}
<div className="bg-liquid-mint/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-mint/20 dark:border-stone-800/60">
<div className="flex items-center gap-3 mb-4">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p>
</div>
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
<button
onClick={() => {
localStorage.removeItem('cookie-consent');
window.location.reload();
}}
className="text-[10px] font-black uppercase tracking-widest border-b border-stone-300 dark:border-stone-700 pb-1 hover:text-liquid-mint transition-colors"
>
Reset Privacy Settings
</button>
</div>
<h2 className="text-2xl font-semibold mt-6">Cookies</h2>
<p className="mt-2">
Diese Website verwendet ein technisch notwendiges Cookie, um deine Datenschutz-Einstellungen (z.B.
Analytics/Chatbot) zu speichern. Ohne dieses Cookie wäre ein Consent-Banner bei jedem Besuch erneut
nötig.
</p>
<h2 className="text-2xl font-semibold mt-6">Analyse- und Tracking-Tools</h2>
<p className="mt-2">
Die nachfolgend beschriebene Analyse- und Tracking-Methode (im
Folgenden Maßnahme genannt) basiert auf Art. 6 Abs. 1 S. 1 lit. f
DSGVO. Durch diese Maßnahme möchten ich eine benutzerfreundliche
Gestaltung sowie eine kontinuierliche Verbesserung meiner Website
sicherstellen. Diese Interessen sind im Sinne der genannten Vorschrift
als berechtigt anzusehen.
<br />
<br />
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes
Interesse an der Analyse und Optimierung unserer Website).
<br />
<br />
Detaillierte Informationen zu den erhobenen Daten und deren
Verarbeitung finden Sie in den nachfolgenden Abschnitten.
<br />
<br />
Zur Analyse der Nutzung meiner Website setze ich Umami ein. Umami
speichert keine IP-Adressen oder Cookies. Alle erfassten Daten sind
anonymisiert. Da ich Umami auf meinem eigenen Server betreibe, erfolgt
keine Weitergabe an Dritte. Weitere Informationen finden Sie unter{" "}
<Link
className="text-blue-700 transition-underline"
href={"https://umami.is"}
>
Umami
</Link>
.
</p>
<p className="mt-4">
Zusätzlich kann diese Website optionale, selbst gehostete
Nutzungsstatistiken erfassen (z.B. Seitenaufrufe, Performance-Metriken),
die erst nach deiner Einwilligung im Consent-Banner aktiviert werden.
</p>
<h2 className="text-2xl font-semibold mt-6">Kontaktformular</h2>
<p className="mt-2">
Wenn Sie das Kontaktformular nutzen, werden Ihre Angaben zur
Bearbeitung Ihrer Anfrage gespeichert. Diese Daten werden nicht an
Dritte weitergegeben und nach Erfüllung des Zwecks gelöscht. <br />
<br />
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
</p>
<h2 className="text-2xl font-semibold mt-6">Chatbot</h2>
<p className="mt-2">
Wenn du den optionalen Chatbot nutzt, werden die von dir eingegebenen
Nachrichten verarbeitet, um eine Antwort zu generieren. Die Verarbeitung
kann dabei über eine selbst gehostete Automations-/Chat-Infrastruktur
(z.B. n8n) erfolgen. Bitte gib im Chat keine sensiblen Daten ein.
<br />
<br />
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung) der
Chatbot wird erst nach Aktivierung im Consent-Banner geladen.
</p>
<h2 className="text-2xl font-semibold mt-6">Social Media Links</h2>
<p className="mt-2">
Unsere Website enthält Links zu GitHub und LinkedIn. Durch das
Anklicken dieser Links gelten die Datenschutzbestimmungen der
jeweiligen Anbieter.
</p>
<h2 className="text-2xl font-semibold mt-6">Weitergabe von Daten</h2>
<div className="mt-2">
Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:
<ul className="list-disc list-inside mt-2">
<li>
Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt
haben,
</li>
<li>
dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO
erforderlich ist,
</li>
<li>
eine gesetzliche Verpflichtung zur Weitergabe nach Art. 6 Abs. 1
S. 1 lit. c DSGVO besteht oder
</li>
<li>
die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung
berechtigter Interessen erforderlich ist.
</li>
</ul>
</div>
<h2 className="text-2xl font-semibold mt-6">
Speicherdauer und Löschung
</h2>
<p className="mt-2">
Ihre Daten werden nur solange gespeichert, wie dies für die Erfüllung
des Verarbeitungszwecks erforderlich ist. Nach Erfüllung des Zwecks
werden Ihre Daten gelöscht.
</p>
<h2 className="text-2xl font-semibold mt-6">Ihre Rechte</h2>
<div className="mt-2">
Sie haben gemäß DSGVO folgende Rechte:
<ul className="list-disc list-inside mt-2">
<li>
Art. 15 DSGVO: Auskunftsrecht über Ihre von mir gespeicherten
Daten
</li>
<li>
Art. 16 DSGVO: Recht auf Berichtigung unrichtiger oder
unvollständiger Daten
</li>
<li>
Art. 17 DSGVO: Recht auf Löschung Ihrer bei mir gespeicherten
Daten (soweit keine gesetzlichen Aufbewahrungspflichten
entgegenstehen)
</li>
<li>
Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung Ihrer
Daten
</li>
<li>Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
<li>
Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung Ihrer
Daten
</li>
</ul>
<br />
Falls Sie eine Einwilligung erklärt haben, können Sie diese jederzeit
widerrufen.
<br />
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde
richten. Eine Liste der Datenschutzbeauftragten sowie deren
Kontaktdaten finden Sie unter:{" "}
<Link
className="text-blue-700 transition-underline"
href={"https://www.bfdi.bund.de/"}
>
https://www.bfdi.bund.de/
</Link>
</div>
<h2 className="text-2xl font-semibold mt-6">Datensicherheit</h2>
<p className="mt-2">
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten
zu schützen. Dazu gehören unter anderem die SSL-Verschlüsselung. Diese
Verschlüsselung erkennen Sie an dem Schloss-Symbol in der Adresszeile
Ihres Browsers und an der URL, die mit &#34;https://&#34; beginnt.
</p>
<h2 className="text-2xl font-semibold mt-6">Kontakt</h2>
<p className="mt-2">
Bei Fragen zur Datenschutzerklärung kontaktieren Sie mich unter{" "}
<Link
href="mailto:info@dk0.dev"
className="text-blue-700 transition-underline"
>
info@dk0.dev
</Link>{" "}
oder nutzen Sie das Kontaktformular auf meiner Website.
</p>
<h2 className="text-2xl font-semibold mt-6">
Änderungen der Datenschutzerklärung
</h2>
<p className="mt-2">
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den
gesetzlichen Anforderungen zu entsprechen und neue Entwicklungen zu
berücksichtigen. Die jeweils aktuelle Datenschutzerklärung finden Sie
auf meiner Website.
</p>
<div className="pt-4 border-t border-gray-700">
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
</div>
</>
)}
</motion.div>
</div>
</main>
<Footer />
</div>

View File

@@ -41,25 +41,6 @@ const ProjectDetail = () => {
const loadedProject = data.projects[0];
setProject(loadedProject);
// Track page view
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'pageview',
projectId: loadedProject.id.toString(),
page: `/projects/${slug}`
})
});
} catch (trackError) {
// Silently fail tracking
if (process.env.NODE_ENV === 'development') {
console.error('Error tracking page view:', trackError);
}
}
}
}
} catch (error) {

View File

@@ -0,0 +1,81 @@
"use client";
import Head from "next/head";
import * as Sentry from "@sentry/nextjs";
export default function SentryExamplePage() {
return (
<div>
<Head>
<title>Sentry Onboarding</title>
<meta name="description" content="Test Sentry for your Next.js app!" />
</Head>
<main
style={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
padding: "2rem",
}}
>
<h1 style={{ fontSize: "2rem", fontWeight: "bold", marginBottom: "1rem" }}>
Sentry Onboarding
</h1>
<p style={{ marginBottom: "1rem" }}>
Get started by sending us a sample error:
</p>
<button
type="button"
style={{
padding: "0.5rem 1rem",
backgroundColor: "#0070f3",
color: "white",
border: "none",
borderRadius: "0.25rem",
cursor: "pointer",
}}
onClick={async () => {
Sentry.captureException(new Error("This is your first error!"));
try {
const res = await fetch("/api/sentry-example-api");
if (!res.ok) {
throw new Error("Sentry Example API Error");
}
} catch (err) {
Sentry.captureException(err);
}
}}
>
Throw error!
</button>
<p style={{ marginTop: "2rem", fontSize: "0.875rem", color: "#666" }}>
Next, look for the error on the{" "}
<a
style={{ color: "#0070f3", textDecoration: "underline" }}
href="https://dk0.sentry.io/issues/?project=4510751388926032"
target="_blank"
rel="noopener noreferrer"
>
Issues Page
</a>
</p>
<p style={{ fontSize: "0.875rem", color: "#666" }}>
For more information, see{" "}
<a
style={{ color: "#0070f3", textDecoration: "underline" }}
href="https://docs.sentry.io/platforms/javascript/guides/nextjs/"
target="_blank"
rel="noopener noreferrer"
>
https://docs.sentry.io/platforms/javascript/guides/nextjs/
</a>
</p>
</main>
</div>
);
}

View File

@@ -1,297 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useWebVitals } from '@/lib/useWebVitals';
import { trackEvent, trackPageLoad } from '@/lib/analytics';
interface AnalyticsProviderProps {
children: React.ReactNode;
}
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
// Initialize Web Vitals tracking - wrapped to prevent crashes
// Hooks must be called unconditionally, but the hook itself handles errors
useWebVitals();
useEffect(() => {
if (typeof window === 'undefined') return;
// Wrap entire effect in try-catch to prevent any errors from breaking the app
try {
// Track page view
const trackPageView = async () => {
const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null;
// Track to Umami (if available)
trackEvent('page-view', {
url: path,
referrer: document.referrer,
timestamp: Date.now(),
});
// Track to our API
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'pageview',
projectId: projectId,
page: path
})
});
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.error('Error tracking page view:', error);
}
}
};
// Track page load performance - wrapped in try-catch
try {
trackPageLoad();
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking page load:', error);
}
}
// Track initial page view
trackPageView();
// Track performance metrics to our API
const trackPerformanceToAPI = async () => {
try {
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
return;
}
// Get current page path to extract project ID if on project page
const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null;
// Wait for page to fully load
setTimeout(async () => {
try {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
const paintEntries = performance.getEntriesByType('paint');
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
const performanceData = {
loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
fcp: fcp ? fcp.startTime : 0,
lcp: lcp ? lcp.startTime : 0,
ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
cls: 0, // Will be updated by CLS observer
fid: 0, // Will be updated by FID observer
si: 0 // Speed Index - would need to calculate
};
// Send performance data
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'performance',
projectId: projectId,
page: path,
performance: performanceData
})
});
} catch (error) {
// Silently fail - performance tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error collecting performance data:', error);
}
}
}, 2000); // Wait 2 seconds for page to stabilize
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.error('Error tracking performance:', error);
}
}
};
// Track performance after page load
if (document.readyState === 'complete') {
trackPerformanceToAPI();
} else {
window.addEventListener('load', trackPerformanceToAPI);
}
// Track route changes (for SPA navigation)
const handleRouteChange = () => {
setTimeout(() => {
trackPageView();
trackPageLoad();
}, 100);
};
// Listen for popstate events (back/forward navigation)
window.addEventListener('popstate', handleRouteChange);
// Track user interactions
const handleClick = (event: MouseEvent) => {
try {
if (typeof window === 'undefined') return;
const target = event.target as HTMLElement | null;
if (!target) return;
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
const className = target.className;
const id = target.id;
trackEvent('click', {
element,
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined,
id: id || undefined,
url: window.location.pathname,
});
} catch (error) {
// Silently fail - click tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking click:', error);
}
}
};
// Track form submissions
const handleSubmit = (event: SubmitEvent) => {
try {
if (typeof window === 'undefined') return;
const form = event.target as HTMLFormElement | null;
if (!form) return;
trackEvent('form-submit', {
formId: form.id || undefined,
formClass: form.className || undefined,
url: window.location.pathname,
});
} catch (error) {
// Silently fail - form tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking form submit:', error);
}
}
};
// Track scroll depth
let maxScrollDepth = 0;
const firedScrollMilestones = new Set<number>();
const handleScroll = () => {
try {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const scrollHeight = document.documentElement.scrollHeight;
const innerHeight = window.innerHeight;
if (scrollHeight <= innerHeight) return; // No scrollable content
const scrollDepth = Math.round(
(window.scrollY / (scrollHeight - innerHeight)) * 100
);
if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
// Track each milestone once (avoid spamming events on every scroll tick)
const milestones = [25, 50, 75, 90];
for (const milestone of milestones) {
if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
firedScrollMilestones.add(milestone);
trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
}
}
} catch (error) {
// Silently fail - scroll tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking scroll:', error);
}
}
};
// Add event listeners
document.addEventListener('click', handleClick);
document.addEventListener('submit', handleSubmit);
window.addEventListener('scroll', handleScroll, { passive: true });
// Track errors
const handleError = (event: ErrorEvent) => {
try {
if (typeof window === 'undefined') return;
trackEvent('error', {
message: event.message || 'Unknown error',
filename: event.filename || undefined,
lineno: event.lineno || undefined,
colno: event.colno || undefined,
url: window.location.pathname,
});
} catch (error) {
// Silently fail - error tracking should not cause more errors
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking error event:', error);
}
}
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
try {
if (typeof window === 'undefined') return;
trackEvent('unhandled-rejection', {
reason: event.reason?.toString() || 'Unknown rejection',
url: window.location.pathname,
});
} catch (error) {
// Silently fail - error tracking should not cause more errors
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking unhandled rejection:', error);
}
}
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
// Cleanup
return () => {
try {
// Remove load handler if we added it
window.removeEventListener('load', trackPerformanceToAPI);
window.removeEventListener('popstate', handleRouteChange);
document.removeEventListener('click', handleClick);
document.removeEventListener('submit', handleSubmit);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
} catch {
// Silently fail during cleanup
}
};
} catch (error) {
// If anything fails, log but don't break the app
if (process.env.NODE_ENV === 'development') {
console.error('AnalyticsProvider initialization error:', error);
}
// Return empty cleanup function
return () => {};
}
}, []);
// Always render children, even if analytics fails
return <>{children}</>;
};

View File

@@ -1,169 +1,45 @@
"use client";
import { motion, useMotionValue, useTransform, useSpring } from "framer-motion";
import { motion } from "framer-motion";
import { useEffect, useState } from "react";
const BackgroundBlobs = () => {
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const springConfig = { damping: 50, stiffness: 50, mass: 2 };
const springX = useSpring(mouseX, springConfig);
const springY = useSpring(mouseY, springConfig);
// Very subtle parallax offsets
const x1 = useTransform(springX, (value) => value / 30);
const y1 = useTransform(springY, (value) => value / 30);
const x2 = useTransform(springX, (value) => value / -25);
const y2 = useTransform(springY, (value) => value / -25);
const x3 = useTransform(springX, (value) => value / 20);
const y3 = useTransform(springY, (value) => value / 20);
const x4 = useTransform(springX, (value) => value / -35);
const y4 = useTransform(springY, (value) => value / -35);
const x5 = useTransform(springX, (value) => value / 15);
const y5 = useTransform(springY, (value) => value / 15);
// Prevent hydration mismatch
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
const handleMouseMove = (e: MouseEvent) => {
const x = e.clientX - window.innerWidth / 2;
const y = e.clientY - window.innerHeight / 2;
mouseX.set(x);
mouseY.set(y);
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY, mounted]);
if (!mounted) return null;
return (
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
{/* Mint blob - top left */}
<motion.div
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[100px] mix-blend-multiply"
style={{ x: x1, y: y1 }}
animate={{
scale: [1, 1.15, 1],
rotate: [0, 45, 0],
}}
transition={{
duration: 40,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.15, 1] }}
transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
{/* Lavender blob - top right */}
<motion.div
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[100px] mix-blend-multiply"
style={{ x: x2, y: y2 }}
animate={{
scale: [1, 1.1, 1],
rotate: [0, -30, 0],
}}
transition={{
duration: 45,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
{/* Rose blob - bottom left */}
<motion.div
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[100px] mix-blend-multiply"
style={{ x: x3, y: y3 }}
animate={{
scale: [1, 1.2, 1],
rotate: [0, 60, 0],
}}
transition={{
duration: 50,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
{/* Peach blob - middle right */}
<motion.div
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[120px] mix-blend-multiply"
style={{ x: x4, y: y4 }}
animate={{
scale: [1, 1.25, 1],
rotate: [0, -45, 0],
}}
transition={{
duration: 55,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/>
{/* Blue blob - center */}
<motion.div
className="absolute top-[50%] left-[40%] w-[38vw] h-[38vw] bg-blue-200/30 rounded-full blur-[110px] mix-blend-multiply"
style={{ x: x5, y: y5 }}
animate={{
scale: [1, 1.18, 1],
rotate: [0, 90, 0],
}}
transition={{
duration: 48,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/>
{/* Pink blob - bottom right */}
<motion.div
className="absolute bottom-[10%] right-[-8%] w-[32vw] h-[32vw] bg-pink-200/35 rounded-full blur-[100px] mix-blend-multiply"
animate={{
scale: [1, 1.12, 1],
rotate: [0, -60, 0],
x: [0, -20, 0],
y: [0, 20, 0],
}}
transition={{
duration: 43,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/>
{/* Yellow-green blob - top center */}
<motion.div
className="absolute top-[5%] left-[45%] w-[28vw] h-[28vw] bg-lime-200/30 rounded-full blur-[115px] mix-blend-multiply"
animate={{
scale: [1, 1.22, 1],
rotate: [0, 75, 0],
x: [0, 15, 0],
y: [0, -15, 0],
}}
transition={{
duration: 52,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
animate={{ scale: [1, 1.25, 1] }}
transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
/>
</div>
);

View File

@@ -0,0 +1,59 @@
/**
* Server Component für i18n-Texte
* Nutzt Directus mit Fallback auf next-intl/JSON
*/
import { getLocalizedMessage, getLocalizedContent } from '@/lib/i18n-loader';
interface I18nTextProps {
msgKey: string;
locale: 'en' | 'de';
fallback?: string; // Falls Key nicht in Directus AND nicht in JSON
}
/**
* Zeigt einen kurzen, lokalisierten Text.
* Directus > next-intl/JSON > Fallback > Key selbst.
*/
export async function I18nText({
msgKey,
locale,
fallback,
}: I18nTextProps) {
const text = await getLocalizedMessage(msgKey, locale);
return <>{text || fallback || msgKey}</>;
}
interface I18nContentProps {
slug: string;
locale: 'en' | 'de';
fallback?: React.ReactNode;
}
/**
* Zeigt ein längeres, lokalisiertes Inhaltsblöck.
* Nur Directus, kein JSON-Fallback.
*/
export async function I18nContent({
slug,
locale,
fallback,
}: I18nContentProps) {
const page = await getLocalizedContent(slug, locale);
if (!page?.content) {
return <>{fallback || null}</>;
}
// Wenn content ein String ist (Markdown/Plain Text):
if (typeof page.content === 'string') {
return <div className="prose prose-stone max-w-none">{page.content}</div>;
}
// Wenn content ein JSON-Objekt ist (Rich Text Editor):
return (
<div className="prose prose-stone max-w-none">
{JSON.stringify(page.content)}
</div>
);
}

View File

@@ -1,5 +0,0 @@
'use client';
export const LiquidCursor = () => {
return null;
};

View File

@@ -91,7 +91,9 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
});
if (!response.ok) {
console.warn('Failed to load projects:', response.status);
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to load projects:', response.status);
}
setProjects([]);
return;
}

View File

@@ -1,139 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { trackEvent } from '@/lib/analytics';
interface PerformanceData {
timestamp: string;
url: string;
metrics: {
LCP?: number;
FID?: number;
CLS?: number;
FCP?: number;
TTFB?: number;
};
}
export const PerformanceDashboard: React.FC = () => {
const [performanceData, setPerformanceData] = useState<PerformanceData[]>([]);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// This would typically fetch from your Umami instance or database
// For now, we'll show a placeholder
const mockData: PerformanceData[] = [
{
timestamp: new Date().toISOString(),
url: '/',
metrics: {
LCP: 1200,
FID: 45,
CLS: 0.1,
FCP: 800,
TTFB: 200,
},
},
];
setPerformanceData(mockData);
}, []);
const getPerformanceGrade = (metric: string, value: number): string => {
switch (metric) {
case 'LCP':
return value <= 2500 ? 'Good' : value <= 4000 ? 'Needs Improvement' : 'Poor';
case 'FID':
return value <= 100 ? 'Good' : value <= 300 ? 'Needs Improvement' : 'Poor';
case 'CLS':
return value <= 0.1 ? 'Good' : value <= 0.25 ? 'Needs Improvement' : 'Poor';
case 'FCP':
return value <= 1800 ? 'Good' : value <= 3000 ? 'Needs Improvement' : 'Poor';
case 'TTFB':
return value <= 800 ? 'Good' : value <= 1800 ? 'Needs Improvement' : 'Poor';
default:
return 'Unknown';
}
};
const getGradeColor = (grade: string): string => {
switch (grade) {
case 'Good':
return 'text-green-600 bg-green-100';
case 'Needs Improvement':
return 'text-yellow-600 bg-yellow-100';
case 'Poor':
return 'text-red-600 bg-red-100';
default:
return 'text-gray-600 bg-gray-100';
}
};
if (!isVisible) {
return (
<button
onClick={() => {
setIsVisible(true);
trackEvent('dashboard-toggle', { action: 'show' });
}}
className="fixed bottom-4 right-4 bg-white text-stone-700 border border-stone-200 px-4 py-2 rounded-lg shadow-md hover:bg-stone-50 transition-colors z-50"
>
📊 Performance
</button>
);
}
return (
<div className="fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-6 w-96 max-h-96 overflow-y-auto z-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-800">Performance Dashboard</h3>
<button
onClick={() => {
setIsVisible(false);
trackEvent('dashboard-toggle', { action: 'hide' });
}}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>
<div className="space-y-4">
{performanceData.map((data, index) => (
<div key={index} className="border-b border-gray-100 pb-4">
<div className="text-sm text-gray-600 mb-2">
{new Date(data.timestamp).toLocaleString()}
</div>
<div className="text-sm font-medium text-gray-800 mb-2">
{data.url}
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(data.metrics).map(([metric, value]) => {
const grade = getPerformanceGrade(metric, value);
return (
<div key={metric} className="flex justify-between items-center">
<span className="text-xs font-medium text-gray-600">{metric}:</span>
<div className="flex items-center space-x-2">
<span className="text-xs font-mono">{value}ms</span>
<span className={`text-xs px-2 py-1 rounded ${getGradeColor(grade)}`}>
{grade}
</span>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="text-xs text-gray-500">
<div>🟢 Good: Meets recommended thresholds</div>
<div>🟡 Needs Improvement: Below recommended thresholds</div>
<div>🔴 Poor: Significantly below thresholds</div>
</div>
</div>
</div>
);
};

View File

@@ -13,7 +13,6 @@ import {
Github,
RefreshCw
} from 'lucide-react';
// Editor is now a separate page at /editor
interface Project {
id: string;

243
directus-schema/README.md Normal file
View File

@@ -0,0 +1,243 @@
# Directus Schema Import - Anleitung
## 📦 Verfügbare Schemas
- `tech-stack-schema.json` - Tech Stack Categories + Items mit Translations
- `projects-schema.json` - Projects Collection (Coming Soon)
- `hobbies-schema.json` - Hobbies Collection (Coming Soon)
---
## 🚀 Methode 1: Import via Directus UI (Einfachste Methode)
### Voraussetzungen:
- Directus 10.x installiert
- Admin-Zugriff auf https://cms.dk0.dev
### Schritte:
1. **Gehe zu Directus Admin Panel:**
```
https://cms.dk0.dev
```
2. **Öffne Settings:**
- Klicke auf das **Zahnrad-Icon** (⚙️) unten links
- Navigiere zu **Data Model** → **Schema**
3. **Import Schema:**
- Klicke auf **"Import Schema"** Button
- Wähle die Datei: `tech-stack-schema.json`
- ✅ Confirm Import
4. **Überprüfen:**
- Gehe zu **Data Model**
- Du solltest jetzt sehen:
- `tech_stack_categories`
- `tech_stack_categories_translations`
- `tech_stack_items`
---
## ⚡ Methode 2: Import via Directus CLI (Fortgeschritten)
### Voraussetzungen:
- Direkter Zugriff auf Directus Server
- Directus CLI installiert
### Schritte:
1. **Schema-Datei auf Server kopieren:**
```bash
# Via scp oder in deinem Docker Container
scp tech-stack-schema.json user@server:/path/to/directus/
```
2. **Schema anwenden:**
```bash
cd /path/to/directus
npx directus schema apply ./tech-stack-schema.json
```
3. **Verify:**
```bash
npx directus database inspect
```
---
## 🔧 Methode 3: Import via REST API (Automatisch)
Falls du ein Script bevorzugst:
```typescript
// scripts/import-directus-schema.ts
import fetch from 'node-fetch';
import fs from 'fs';
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
async function importSchema(schemaPath: string) {
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
// Import Collections
for (const collection of schema.collections) {
await fetch(`${DIRECTUS_URL}/collections`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(collection)
});
}
// Import Relations
for (const relation of schema.relations) {
await fetch(`${DIRECTUS_URL}/relations`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(relation)
});
}
console.log('✅ Schema imported successfully!');
}
importSchema('./directus-schema/tech-stack-schema.json');
```
**Ausführen:**
```bash
npm install node-fetch @types/node-fetch
npx tsx scripts/import-directus-schema.ts
```
---
## 📝 Nach dem Import: Languages konfigurieren
Directus benötigt die Languages Collection:
### Option A: Manuell in Directus UI
1. Gehe zu **Settings** → **Project Settings** → **Languages**
2. Füge hinzu:
- **English (United States)** - Code: `en-US`
- **German (Germany)** - Code: `de-DE`
### Option B: Via API
```bash
curl -X POST "https://cms.dk0.dev/languages" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "en-US", "name": "English (United States)"}'
curl -X POST "https://cms.dk0.dev/languages" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"code": "de-DE", "name": "German (Germany)"}'
```
---
## 🎨 Nach dem Import: Daten befüllen
### Manuell in Directus UI:
1. **Tech Stack Categories erstellen:**
- Gehe zu **Content** → **Tech Stack Categories**
- Klicke **"Create Item"**
- Fülle aus:
- Key: `frontend`
- Icon: `Globe`
- Status: `published`
- Translations:
- EN: "Frontend & Mobile"
- DE: "Frontend & Mobile"
2. **Tech Stack Items hinzufügen:**
- Gehe zu **Content** → **Tech Stack Items**
- Klicke **"Create Item"**
- Fülle aus:
- Category: `frontend` (Select)
- Name: `Next.js`
- URL: `https://nextjs.org` (optional)
### Oder: Migrations-Script verwenden
```bash
# Coming Soon
npm run migrate:tech-stack
```
---
## ✅ Checklist
- [ ] Schema importiert in Directus
- [ ] Languages konfiguriert (en-US, de-DE)
- [ ] Tech Stack Categories angelegt (4 Kategorien)
- [ ] Tech Stack Items hinzugefügt (~20 Items)
- [ ] Status auf "published" gesetzt
- [ ] GraphQL Query getestet:
```graphql
query {
tech_stack_categories(filter: {status: {_eq: "published"}}) {
key
icon
translations {
name
languages_code { code }
}
items {
name
url
}
}
}
```
---
## 🐛 Troubleshooting
### Error: "Collection already exists"
→ Schema wurde bereits importiert. Lösung:
```bash
# Via Directus UI: Data Model → Delete Collection
# Oder via API:
curl -X DELETE "https://cms.dk0.dev/collections/tech_stack_categories" \
-H "Authorization: Bearer YOUR_TOKEN"
```
### Error: "Language not found"
→ Stelle sicher dass `en-US` und `de-DE` in Languages existieren
### Error: "Unauthorized"
→ Überprüfe `DIRECTUS_STATIC_TOKEN` in .env
---
## 📚 Nächste Schritte
Nach erfolgreichem Import:
1. ✅ **Test GraphQL Query** in Directus
2. ✅ **Erweitere lib/directus.ts** mit `getTechStack()`
3.**Update About.tsx** Component
4.**Deploy & Test** auf Production
---
## 💡 Pro-Tipps
- **Backups:** Exportiere Schema regelmäßig via Directus UI
- **Version Control:** Committe Schema-Files ins Git
- **Automation:** Nutze Directus Webhooks für Auto-Deployment
- **Testing:** Teste Queries im Directus GraphQL Playground

View File

@@ -0,0 +1,404 @@
{
"version": 1,
"directus": "10.x",
"collections": [
{
"collection": "tech_stack_categories",
"meta": {
"icon": "layers",
"display_template": "{{translations.name}}",
"hidden": false,
"singleton": false,
"translations": [
{
"language": "en-US",
"translation": "Tech Stack Categories"
},
{
"language": "de-DE",
"translation": "Tech Stack Kategorien"
}
],
"sort_field": "sort"
},
"schema": {
"name": "tech_stack_categories"
},
"fields": [
{
"field": "id",
"type": "uuid",
"meta": {
"hidden": true,
"readonly": true,
"interface": "input",
"special": ["uuid"]
},
"schema": {
"is_primary_key": true,
"has_auto_increment": false
}
},
{
"field": "status",
"type": "string",
"meta": {
"width": "full",
"options": {
"choices": [
{ "text": "Published", "value": "published" },
{ "text": "Draft", "value": "draft" },
{ "text": "Archived", "value": "archived" }
]
},
"interface": "select-dropdown",
"display": "labels",
"display_options": {
"choices": [
{
"text": "Published",
"value": "published",
"foreground": "#FFFFFF",
"background": "#00C897"
},
{
"text": "Draft",
"value": "draft",
"foreground": "#18222F",
"background": "#D3DAE4"
},
{
"text": "Archived",
"value": "archived",
"foreground": "#FFFFFF",
"background": "#F7971C"
}
]
}
},
"schema": {
"default_value": "draft",
"is_nullable": false
}
},
{
"field": "sort",
"type": "integer",
"meta": {
"interface": "input",
"hidden": true
},
"schema": {}
},
{
"field": "key",
"type": "string",
"meta": {
"interface": "input",
"width": "half",
"options": {
"placeholder": "e.g. frontend, backend, devops"
},
"note": "Unique identifier for the category (no spaces, lowercase)"
},
"schema": {
"is_unique": true,
"is_nullable": false
}
},
{
"field": "icon",
"type": "string",
"meta": {
"interface": "select-dropdown",
"width": "half",
"options": {
"choices": [
{ "text": "Globe (Frontend)", "value": "Globe" },
{ "text": "Server (Backend)", "value": "Server" },
{ "text": "Wrench (Tools)", "value": "Wrench" },
{ "text": "Shield (Security)", "value": "Shield" },
{ "text": "Code", "value": "Code" },
{ "text": "Database", "value": "Database" },
{ "text": "Cloud", "value": "Cloud" }
]
},
"note": "Icon from lucide-react library"
},
"schema": {
"default_value": "Code"
}
},
{
"field": "date_created",
"type": "timestamp",
"meta": {
"special": ["date-created"],
"interface": "datetime",
"readonly": true,
"hidden": true,
"width": "half",
"display": "datetime",
"display_options": {
"relative": true
}
},
"schema": {}
},
{
"field": "date_updated",
"type": "timestamp",
"meta": {
"special": ["date-updated"],
"interface": "datetime",
"readonly": true,
"hidden": true,
"width": "half",
"display": "datetime",
"display_options": {
"relative": true
}
},
"schema": {}
},
{
"field": "translations",
"type": "alias",
"meta": {
"special": ["translations"],
"interface": "translations",
"options": {
"languageField": "languages_code"
}
}
}
]
},
{
"collection": "tech_stack_categories_translations",
"meta": {
"hidden": true,
"icon": "import_export",
"translations": [
{
"language": "en-US",
"translation": "Tech Stack Categories Translations"
}
]
},
"schema": {
"name": "tech_stack_categories_translations"
},
"fields": [
{
"field": "id",
"type": "integer",
"meta": {
"hidden": true
},
"schema": {
"is_primary_key": true,
"has_auto_increment": true
}
},
{
"field": "tech_stack_categories_id",
"type": "uuid",
"meta": {
"hidden": true
},
"schema": {}
},
{
"field": "languages_code",
"type": "string",
"meta": {
"width": "half",
"interface": "select-dropdown-m2o",
"options": {
"template": "{{name}}"
}
},
"schema": {}
},
{
"field": "name",
"type": "string",
"meta": {
"interface": "input",
"options": {
"placeholder": "e.g. Frontend & Mobile"
},
"note": "Translated category name"
},
"schema": {}
}
]
},
{
"collection": "tech_stack_items",
"meta": {
"icon": "code",
"display_template": "{{name}} ({{category.translations.name}})",
"hidden": false,
"singleton": false,
"translations": [
{
"language": "en-US",
"translation": "Tech Stack Items"
},
{
"language": "de-DE",
"translation": "Tech Stack Items"
}
],
"sort_field": "sort"
},
"schema": {
"name": "tech_stack_items"
},
"fields": [
{
"field": "id",
"type": "uuid",
"meta": {
"hidden": true,
"readonly": true,
"interface": "input",
"special": ["uuid"]
},
"schema": {
"is_primary_key": true,
"has_auto_increment": false
}
},
{
"field": "sort",
"type": "integer",
"meta": {
"interface": "input",
"hidden": true
},
"schema": {}
},
{
"field": "category",
"type": "uuid",
"meta": {
"interface": "select-dropdown-m2o",
"width": "half",
"display": "related-values",
"display_options": {
"template": "{{translations.name}}"
}
},
"schema": {}
},
{
"field": "name",
"type": "string",
"meta": {
"interface": "input",
"width": "half",
"options": {
"placeholder": "e.g. Next.js, Docker, Tailwind CSS"
},
"note": "Technology name (same in all languages)"
},
"schema": {
"is_nullable": false
}
},
{
"field": "url",
"type": "string",
"meta": {
"interface": "input",
"width": "half",
"options": {
"placeholder": "https://nextjs.org"
},
"note": "Official website (optional)"
},
"schema": {}
},
{
"field": "icon_url",
"type": "string",
"meta": {
"interface": "input",
"width": "half",
"options": {
"placeholder": "https://..."
},
"note": "Custom icon/logo URL (optional)"
},
"schema": {}
},
{
"field": "date_created",
"type": "timestamp",
"meta": {
"special": ["date-created"],
"interface": "datetime",
"readonly": true,
"hidden": true
},
"schema": {}
},
{
"field": "date_updated",
"type": "timestamp",
"meta": {
"special": ["date-updated"],
"interface": "datetime",
"readonly": true,
"hidden": true
},
"schema": {}
}
]
}
],
"relations": [
{
"collection": "tech_stack_categories_translations",
"field": "tech_stack_categories_id",
"related_collection": "tech_stack_categories",
"meta": {
"one_field": "translations",
"sort_field": null,
"one_deselect_action": "delete"
},
"schema": {
"on_delete": "CASCADE"
}
},
{
"collection": "tech_stack_categories_translations",
"field": "languages_code",
"related_collection": "languages",
"meta": {
"one_field": null,
"sort_field": null,
"one_deselect_action": "nullify"
},
"schema": {
"on_delete": "SET NULL"
}
},
{
"collection": "tech_stack_items",
"field": "category",
"related_collection": "tech_stack_categories",
"meta": {
"one_field": "items",
"sort_field": "sort",
"one_deselect_action": "nullify"
},
"schema": {
"on_delete": "SET NULL"
}
}
]
}

View File

@@ -1,7 +1,7 @@
services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
image: postgres:16-alpine
container_name: portfolio_postgres_dev
environment:
POSTGRES_DB: portfolio_dev
@@ -36,7 +36,7 @@ services:
networks:
portfolio_dev:
driver: bridge
external: true
volumes:
postgres_dev_data:

View File

@@ -12,6 +12,8 @@ services:
- NODE_ENV=production
- DATABASE_URL=postgresql://portfolio_user:portfolio_pass@postgres:5432/portfolio_db?schema=public
- REDIS_URL=redis://redis:6379
- DIRECTUS_URL=${DIRECTUS_URL:-https://cms.dk0.dev}
- DIRECTUS_STATIC_TOKEN=${DIRECTUS_STATIC_TOKEN:-}
- NEXT_PUBLIC_BASE_URL=https://dk0.dev
- MY_EMAIL=${MY_EMAIL:-contact@dk0.dev}
- MY_INFO_EMAIL=${MY_INFO_EMAIL:-info@dk0.dev}
@@ -60,7 +62,6 @@ services:
- POSTGRES_PASSWORD=portfolio_pass
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
networks:
- portfolio_net
healthcheck:
@@ -112,6 +113,6 @@ volumes:
networks:
portfolio_net:
driver: bridge
external: true
proxy:
external: true

View File

@@ -47,6 +47,8 @@ services:
image: postgres:16-alpine
container_name: portfolio-postgres
restart: unless-stopped
ports:
- "5432:5432" # Expose für lokale Development
environment:
- POSTGRES_DB=portfolio_db
- POSTGRES_USER=portfolio_user

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