Compare commits

...

122 Commits

Author SHA1 Message Date
denshooter
aee811309b fix: scroll to top on locale switch and remove dashes from hero text
All checks were successful
CI / CD / test-build (push) Successful in 10m8s
CI / CD / deploy-dev (push) Has been skipped
CI / CD / deploy-production (push) Successful in 1m15s
- HeaderClient: track locale prop changes with useRef and call
  window.scrollTo on switch to reliably reset scroll position
- messages/en.json + de.json: replace em dash with comma and remove
  hyphens from Self-Hoster/Full-Stack in hero description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 14:37:56 +01:00
denshooter
48a29cd872 fix: pass locale explicitly to Hero and force-dynamic on locale-sensitive API routes
All checks were successful
CI / CD / test-build (push) Successful in 10m11s
CI / CD / deploy-dev (push) Has been skipped
CI / CD / deploy-production (push) Successful in 1m28s
- Hero.tsx: pass locale prop directly to getTranslations instead of
  relying on setRequestLocale async storage, which can be lost during
  Next.js RSC streaming
- book-reviews route: replace revalidate=300 with force-dynamic to
  prevent cached English responses being served to German locale requests
- content/page route: add runtime=nodejs and force-dynamic (was missing
  both, violating CLAUDE.md API route conventions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 13:43:26 +01:00
denshooter
c95fc3101b chore: merge branch 'dev' into 'production' (Release: Design Overhaul & Admin Redesign)
All checks were successful
CI / CD / test-build (push) Successful in 10m9s
CI / CD / deploy-dev (push) Has been skipped
CI / CD / deploy-production (push) Successful in 24s
2026-03-08 13:18:26 +01:00
denshooter
07b155369d feat: redesign admin panel to match Liquid Editorial Bento design system
All checks were successful
CI / CD / test-build (push) Successful in 10m9s
CI / CD / deploy-dev (push) Successful in 1m16s
CI / CD / deploy-production (push) Has been skipped
- Login page: stone/dark palette, liquid ambient blobs, dk0.dev branding,
  gradient accent bar, large rounded card, site-matching button/input styles
- Lockout/loading states: dark mode support, emerald spinner, red gradient bar
- Dashboard navbar: gradient accent bar, monospace branding, pill-style tab buttons
  with dark/light active state, improved mobile menu grid layout
- Stats cards: liquid-* gradient backgrounds per metric (emerald, sky, purple,
  amber, pink, teal) with matching icon colors and rounded-3xl corners
- Section headings: uppercase tracking-tighter font-black with emerald accent dot
- Activity/settings cards: white/dark-stone background, rounded-3xl, dark mode
- Removed framer-motion from manage/page.tsx; replaced admin-glass* CSS classes
  with proper Tailwind throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:37:03 +01:00
denshooter
dda996f0f8 chore: remove Telegram notification from contact form
All checks were successful
CI / CD / test-build (push) Successful in 10m15s
CI / CD / deploy-dev (push) Successful in 1m15s
CI / CD / deploy-production (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:19:52 +01:00
denshooter
63960f7581 feat: dark email design + Telegram notification for contact form
Some checks failed
CI / CD / deploy-dev (push) Has been cancelled
CI / CD / deploy-production (push) Has been cancelled
CI / CD / test-build (push) Has been cancelled
Notification email (to Dennis):
- Complete dark-theme redesign: #0c0c0c bg, #141414 card, gradient top bar
- Sender avatar with liquid-mint/sky gradient + initial letter
- Subject displayed as pill badge
- Message in styled blockquote with mint left border
- Gradient "Direkt antworten" CTA button
- replyTo header already set so email Reply goes directly to sender

Telegram notification:
- sendTelegramNotification() fires after successful email send (fire-and-forget)
- Uses TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID env vars (silently skips if absent)
- HTML-formatted message with emojis, name/email/subject/message preview
- Inline keyboard button "Per E-Mail antworten" with pre-filled mailto link
- Never blocks the contact form response if Telegram fails

Reply email templates (respond/route.tsx):
- Same dark design system as notification email
- baseEmail() generates consistent header + footer
- messageCard() helper for styled message blocks with colored left border
- ctaButton() helper for gradient CTA buttons
- Templates: welcome, project, quick, reply — all updated to dark theme

Required new env vars:
  TELEGRAM_BOT_TOKEN=<from @BotFather>
  TELEGRAM_CHAT_ID=<your chat/user ID>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 00:17:39 +01:00
denshooter
bdf02b2a3a fix: eliminate 2s LCP rendering delay from Directus timeout on Hero
All checks were successful
CI / CD / test-build (push) Successful in 10m11s
CI / CD / deploy-dev (push) Successful in 1m17s
CI / CD / deploy-production (push) Has been skipped
The Hero server component awaited getMessages(locale) which called Directus
with a 2-second timeout. On testing.dk0.dev (or when Directus is unreachable),
this blocked the entire Hero render for ~2s → LCP 3.0s / 2320ms rendering delay.

Changes:
- Hero.tsx: remove getMessages() call entirely; use t() for all strings
- messages/en.json + de.json: add hero.badge, hero.line1, hero.line2 keys
- lib/i18n-loader.ts: invert lookup order — JSON first, Directus only as
  override for keys absent from JSON. Previously Directus was tried first
  for every key, causing ~49 parallel network requests per page load in
  HomePageServer (aboutT + projectsT + contactT + footerT translations).
  Now all JSON-backed keys return instantly without any network I/O.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 22:36:03 +01:00
denshooter
dacec18956 perf: eliminate next-themes and framer-motion from initial JS bundle
All checks were successful
CI / CD / test-build (push) Successful in 10m10s
CI / CD / deploy-dev (push) Successful in 1m46s
CI / CD / deploy-production (push) Has been skipped
- Replace next-themes (38 KiB) with a tiny custom ThemeProvider (~< 1 KiB)
  using localStorage + classList.toggle for theme management
- Add FOUC-prevention inline script in layout.tsx to apply saved theme
  before React hydrates
- Remove framer-motion from Header.tsx: nav entry now uses CSS slideDown
  keyframe, mobile menu uses CSS opacity/translate transitions
- Remove framer-motion from ThemeToggle.tsx: use Tailwind hover/active scale
- Remove framer-motion from legal-notice and privacy-policy pages
- Update useTheme import in ThemeToggle to use custom ThemeProvider
- Add slideDown keyframe to tailwind.config.ts
- Update tests to mock custom ThemeProvider instead of next-themes

Result: framer-motion moves from "First Load JS shared by all" to lazy
chunks; next-themes chunk eliminated entirely; -38 KiB from initial bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 17:39:29 +01:00
denshooter
7f7ed39b0e fix: prevent image/badge cutoff on iPad in Hero section
All checks were successful
CI / CD / test-build (push) Successful in 10m11s
CI / CD / deploy-dev (push) Successful in 1m17s
CI / CD / deploy-production (push) Has been skipped
overflow-hidden on the <section> was clipping the -bottom-6 badge
and the image bottom on iPad viewports where content sits near the
section edge. Move overflow-hidden to the blobs container (absolute
inset-0) so the blobs are still clipped but the image and badge can
render freely. Add pb-10 sm:pb-16 bottom padding so the badge always
has clearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 15:14:30 +01:00
denshooter
1c49289386 perf: remove TipTap/ProseMirror from client bundle, lazy-load below-fold sections
All checks were successful
CI / CD / test-build (push) Successful in 10m11s
CI / CD / deploy-dev (push) Successful in 1m23s
CI / CD / deploy-production (push) Has been skipped
TipTap (ProseMirror) was causing:
- chunks 1007 (85 KiB) and 3207 (58 KiB) in the initial bundle
- Array.prototype.at/flat/flatMap, Object.fromEntries/hasOwn polyfills
  (ProseMirror bundles core-js for these — the 12 KiB legacy JS flag)
- 2+ seconds of main thread blocking on mobile

Fix: move HTML conversion to the server (API route) and pass the
resulting HTML string to the client, eliminating the need to import
richTextToSafeHtml (and transitively TipTap) in any client component.

Changes:
- app/api/content/page/route.ts: call richTextToSafeHtml server-side,
  add html: string to response alongside existing content
- app/components/RichTextClient.tsx: accept html string, remove all
  TipTap imports — TipTap/ProseMirror now has zero client bundle cost
- app/components/About.tsx, Contact.tsx: use cmsHtml from API
- app/legal-notice/page.tsx, privacy-policy/page.tsx: same
- app/components/ClientWrappers.tsx: change static imports of About,
  Projects, Contact, Footer to next/dynamic so their JS is in
  separate lazy-loaded chunks, not in the initial bundle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 14:57:36 +01:00
denshooter
34a81a6437 fix: resolve TypeScript errors in CI type-check
All checks were successful
CI / CD / test-build (push) Successful in 10m10s
CI / CD / deploy-dev (push) Successful in 1m53s
CI / CD / deploy-production (push) Has been skipped
- next.config.ts: cssChunking 'loose' → false ('loose' not in type)
- ActivityFeed.test.tsx: remove always-truthy TS2872 literal expression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 12:31:09 +01:00
denshooter
fa48610e3e perf: cut CI time, fix CSS chain, fix iPad hero layout, fix contrast
Some checks failed
CI / CD / test-build (push) Failing after 5m31s
CI / CD / deploy-dev (push) Has been skipped
CI / CD / deploy-production (push) Has been skipped
CI:
- Replace `npm run build` with `tsc --noEmit` in test-build job
  → eliminates duplicate Next.js build (~5 min saved per push)
  → Docker deploy job already does the full build

PageSpeed:
- Add `cssChunking: 'loose'` to merge CSS into one chunk and break
  the 84dc7384→3aefc04b render-blocking CSS waterfall chain (450ms mobile)
- Remove @shadergradient/react, @react-three/fiber, three from
  package.json — packages were already unused in code, removes any
  residual bundling risk for chunk 7001

Hero:
- Change lg:flex-row → xl:flex-row so iPad (1024px) stays in column
  layout; the 9.5rem heading overflowed into the image at lg causing
  the photo to be clipped by overflow-hidden on the section
- Update image sizes attribute to match new xl breakpoint
- Fix contrast: "GET IN TOUCH" link text-stone-500 → text-stone-700
  (contrast 3.7:1 → 7:1, now WCAG AA compliant)
- Change text-center/justify-center to xl: variants to match layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 07:18:54 +01:00
denshooter
a38f97c318 fix: pass DIRECTUS_STATIC_TOKEN and N8N_API_KEY to dev container
All checks were successful
CI / CD / test-build (push) Successful in 11m2s
CI / CD / deploy-dev (push) Successful in 26s
CI / CD / deploy-production (push) Has been skipped
Adds the missing env vars to deploy-dev so testing.dk0.dev has
access to Directus CMS data (projects, books) and n8n features,
matching the production container configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 00:21:21 +01:00
denshooter
d7958b3841 feat: Hardcover→Directus book sync + fix empty states for projects/books
All checks were successful
CI / CD / test-build (push) Successful in 11m5s
CI / CD / deploy-dev (push) Successful in 1m18s
CI / CD / deploy-production (push) Has been skipped
- Add POST /api/n8n/hardcover/sync-books — n8n calls this after detecting
  finished books in Hardcover. Authenticates via N8N_SECRET_TOKEN/N8N_API_KEY,
  deduplicates by hardcover_id, creates new book_reviews entries in Directus.

- Add getBookReviewByHardcoverId() + createBookReview() to lib/directus.ts.
  Check uses GraphQL filter; create uses Directus REST POST /items/book_reviews.

- ReadBooks: replace silent return null with a visible empty state so the
  section stays visible with a hint until the n8n sync populates it.

- Projects: add "No projects yet." placeholder instead of blank grid when
  both Directus and PostgreSQL return no data.

- Add home.about.readBooks.empty i18n key (EN + DE).

n8n workflow setup:
  Schedule → HTTP Hardcover GraphQL (books_read) → Code (transform) →
  POST /api/n8n/hardcover/sync-books with array of { hardcover_id, title,
  author, image, rating, finished_at }

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 00:02:52 +01:00
denshooter
7f9d39c275 perf: eliminate Three.js/WebGL, fix render-blocking CSS, add dev team agents
All checks were successful
CI / CD / test-build (push) Successful in 10m59s
CI / CD / deploy-dev (push) Successful in 1m54s
CI / CD / deploy-production (push) Has been skipped
- Replace ShaderGradientBackground WebGL shader (3 static spheres) with pure
  CSS radial-gradient divs — moves from ClientProviders (deferred JS) to
  app/layout.tsx as a server component rendered in initial HTML. Eliminates
  @shadergradient/react, three, @react-three/fiber from the JS bundle.
  Removes chunks/7001 (~20s CPU eval) and the 39s main thread block.

- Remove optimizeCss/critters: it was converting <link rel="stylesheet"> to a
  JS-deferred preload, which PageSpeed read as a 410ms sequential CSS chain.
  Both CSS files now load as parallel <link> tags from initial HTML (~150ms).

- Update browserslist safari >= 15 → 15.4 (Array.prototype.at, Object.hasOwn
  are native in 15.4+; eliminates unnecessary SWC compatibility transforms).

- Delete orphaned app/styles/ghostContent.css (never imported anywhere, 3.7KB).

- Add .claude/ dev team setup: 5 subagents (frontend-dev, backend-dev, tester,
  code-reviewer, debugger), 3 skills (/add-section, /review-changes,
  /check-quality), 3 path-scoped rules, settings.json with auto-lint hook.

- Update CLAUDE.md with server/client orchestrator pattern, SSR animation
  safety rules, API route conventions, and improved command reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 23:40:01 +01:00
denshooter
69ae53809b fix: Safari compatibility — polyfill requestIdleCallback and IntersectionObserver
All checks were successful
CI / CD / test-build (push) Successful in 11m8s
CI / CD / deploy-dev (push) Successful in 1m18s
CI / CD / deploy-production (push) Has been skipped
requestIdleCallback is unavailable in Safari < 16.4, causing GatedProviders
to crash during hydration and blank the entire page. Added setTimeout fallback.
Also added IntersectionObserver fallback in ScrollFadeIn for safety.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 19:25:38 +01:00
denshooter
4a8cb5867f docs: update copilot instructions with SSR patterns and CI/CD changes
All checks were successful
CI / CD / test-build (push) Successful in 11m3s
CI / CD / deploy-dev (push) Successful in 1m18s
CI / CD / deploy-production (push) Has been skipped
- Document ScrollFadeIn pattern and Framer Motion SSR pitfall
- Update server/client component architecture section
- Reflect combined CI/CD workflow structure
- Add accessibility contrast requirements
- Streamline commands and conventions sections

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 23:47:17 +01:00
denshooter
77db462c22 fix: add SSR-safe ScrollFadeIn component for scroll animations
Some checks failed
CI / CD / deploy-dev (push) Has been cancelled
CI / CD / deploy-production (push) Has been cancelled
CI / CD / test-build (push) Has been cancelled
ScrollFadeIn uses IntersectionObserver + CSS transitions instead of
Framer Motion's initial prop. Key difference: no inline style in SSR
HTML, so content is visible by default. Animation only activates
after client hydration (hasMounted check).

Wraps About, Projects, Contact, Footer in HomePageServer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 23:41:02 +01:00
denshooter
5fc3236775 fix: remove Framer Motion scroll animations that caused invisible sections
All checks were successful
CI / CD / test-build (push) Successful in 11m3s
CI / CD / deploy-dev (push) Successful in 1m17s
CI / CD / deploy-production (push) Has been skipped
Framer Motion's initial={{ opacity: 0 }} was rendered as inline
style='opacity:0' in SSR HTML. If client-side JS failed to hydrate
properly, sections stayed permanently invisible.

Removed whileInView scroll animations from About, Projects, Contact.
Modal animations (AnimatePresence) kept as they only render on interaction.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 23:05:51 +01:00
denshooter
9ae6ada0a6 fix: remove dynamic() imports for below-fold sections
All checks were successful
CI / CD / test-build (push) Successful in 11m5s
CI / CD / deploy-dev (push) Successful in 1m17s
CI / CD / deploy-production (push) Has been skipped
dynamic() caused Framer Motion's initial opacity:0 to be baked into
SSR HTML, but client-side hydration never triggered the animations.
Direct imports ensure Framer Motion properly takes over on hydration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 22:02:05 +01:00
denshooter
08315433d1 fix: enable SSR for below-fold sections (About, Projects, Contact, Footer)
All checks were successful
CI / CD / test-build (push) Successful in 11m4s
CI / CD / deploy-dev (push) Successful in 1m19s
CI / CD / deploy-production (push) Has been skipped
ssr:false caused sections to only render client-side, making them
invisible if any JS error occurred. Keep dynamic() for code-splitting
but allow server-side rendering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 21:37:44 +01:00
denshooter
10a545f014 fix: replace img tags with next/image, fix useEffect deps, suppress test mock warnings
All checks were successful
CI / CD / test-build (push) Successful in 11m2s
CI / CD / deploy-dev (push) Successful in 1m4s
CI / CD / deploy-production (push) Has been skipped
- projects/page.tsx & projects/[slug]/page.tsx: <img> → <Image fill unoptimized>
- ActivityFeed.tsx: add allQuotes.length to useEffect deps
- Test mocks: eslint-disable for intentional <img> in next/image mocks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 15:40:19 +01:00
denshooter
d80c936c60 refactor: add production deploy to combined CI/CD workflow
Some checks failed
CI / CD / test-build (push) Successful in 11m4s
CI / CD / deploy-production (push) Has been cancelled
CI / CD / deploy-dev (push) Has been cancelled
All 3 jobs in one file:
- test-build: lint, test, build (all branches)
- deploy-dev: Docker + deploy (dev only, needs test-build)
- deploy-production: Docker + deploy (production only, needs test-build)

Removes separate production-deploy.yml.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 15:23:12 +01:00
denshooter
2db9018477 refactor: combine CI and dev-deploy into single workflow
Some checks failed
CI / CD / test-build (push) Has been cancelled
CI / CD / deploy-dev (push) Has been cancelled
Job 1 (test-build): lint, test, build — runs on all branches/PRs
Job 2 (deploy-dev): Docker build + deploy — only on dev, after tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 15:13:47 +01:00
denshooter
eff17f76d3 chore: enable dev-deploy workflow
Some checks failed
Gitea CI / test-build (push) Has been cancelled
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 14m16s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 15:10:46 +01:00
denshooter
30d0e597c2 fix: use production DB/Redis for dev deployment instead of non-existent dev containers
Some checks failed
Gitea CI / test-build (push) Has been cancelled
The dev-deploy workflow was trying to spin up separate portfolio_postgres_dev
and portfolio_redis_dev containers, which don't exist on the server. Now it
reuses the existing production portfolio-postgres and portfolio-redis on
the portfolio_net network.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 15:09:04 +01:00
denshooter
74b73d1b84 perf: add Docker build cache for Next.js
All checks were successful
Gitea CI / test-build (push) Successful in 11m6s
Mount .next/cache as a BuildKit cache volume during build to persist
the Next.js build cache across Docker rebuilds. Eliminates the
'No build cache found' warning and speeds up subsequent builds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 14:45:55 +01:00
denshooter
42850ea17c fix: prevent crash loop when database is unreachable
Some checks failed
Gitea CI / test-build (push) Has been cancelled
Make prisma migrate deploy failure non-fatal in start-with-migrate.js.
Previously, migration failure caused process.exit() which killed the
container, triggering an infinite restart loop. Now logs a warning
and starts the Next.js server anyway (app has DB fallbacks).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 14:38:59 +01:00
denshooter
9fd530c68f perf: convert Hero to server component for faster LCP
All checks were successful
Gitea CI / test-build (push) Successful in 11m9s
- Hero now renders server-side, eliminating JS dependency for LCP text
- CMS messages fetched server-side instead of client useEffect
- Removes Hero from client JS bundle (~5KB less)
- LCP element (hero paragraph) now in initial HTML response
- Eliminates 2,380ms 'element rendering delay' reported by PSI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 14:16:58 +01:00
denshooter
60ea4e99be chore: remove Sentry integration
All checks were successful
Gitea CI / test-build (push) Successful in 11m8s
Remove @sentry/nextjs and all related files since it was never actively used.
- Delete sentry.server.config.ts, sentry.edge.config.ts
- Delete sentry-example-page and sentry-example-api routes
- Clean up instrumentation.ts, global-error.tsx, middleware.ts
- Remove Sentry env vars from env.example and docs
- Update CLAUDE.md, copilot-instructions.md, PRODUCTION_READINESS.md

Middleware bundle reduced from 86KB to 34.8KB (-51KB).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 13:00:34 +01:00
denshooter
de3ef37b48 perf: remove framer-motion and lucide-react from critical path
All checks were successful
Gitea CI / test-build (push) Successful in 11m36s
- Remove framer-motion from Hero.tsx and HeaderClient.tsx, replace with CSS animations/transitions
- Replace lucide-react icons (Menu, X, Mail) with inline SVGs in HeaderClient.tsx
- Lazy-load About, Projects, Contact, Footer via dynamic() imports in ClientWrappers.tsx
- Defer ShaderGradient/BackgroundBlobs loading via requestIdleCallback in ClientProviders.tsx
- Remove AnimatePresence page wrapper that caused full re-renders
- Enable experimental.optimizeCss (critters) for critical CSS inlining
- Add fadeIn keyframe to Tailwind config for CSS-based animations

Homepage JS reduced from 563KB to 438KB (-125KB).
Eliminates ~39s main thread work from WebGL init and layout thrashing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 11:13:10 +01:00
denshooter
f62db69289 perf: fix PageSpeed Insights issues (WebGL errors, bfcache, redirects, a11y)
All checks were successful
Gitea CI / test-build (push) Successful in 11m38s
- Add WebGL support detection in ShaderGradientBackground to prevent console errors
- Add .catch() fallback to ShaderGradientBackground dynamic import
- Remove hardcoded aria-label from consent banner minimize button (fixes label-content-name-mismatch)
- Use rewrite instead of redirect for root locale routing (eliminates one redirect hop)
- Change n8n API cache headers from no-store to no-cache (enables bfcache)
- Add three and @react-three/fiber to optimizePackageImports for better tree-shaking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 01:29:32 +01:00
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
195 changed files with 17466 additions and 13611 deletions

View File

@@ -0,0 +1,45 @@
---
name: backend-dev
description: Backend API developer for this portfolio. Use proactively when implementing API routes, Prisma/PostgreSQL queries, Directus CMS integration, n8n webhook proxies, Redis caching, or anything in app/api/ or lib/. Handles graceful fallbacks and rate limiting.
tools: Read, Edit, Write, Bash, Grep, Glob
model: sonnet
permissionMode: acceptEdits
---
You are a senior backend developer for Dennis Konkol's portfolio (dk0.dev).
## Stack you own
- **Next.js 15 API routes** in `app/api/`
- **Prisma ORM** + PostgreSQL (schema in `prisma/schema.prisma`)
- **Directus GraphQL** via `lib/directus.ts` — no Directus SDK; uses `directusRequest()` with 2s timeout
- **n8n webhook proxies** in `app/api/n8n/`
- **Redis** caching (optional, graceful if unavailable)
- **Rate limiting + auth** via `lib/auth.ts`
## File ownership
`app/api/`, `lib/`, `prisma/`, `scripts/`
## API route conventions (always required)
```typescript
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
```
Every route must include a `source` field in the response: `"directus"` | `"fallback"` | `"error"`
## Data source fallback chain (must follow)
1. Directus CMS (if `DIRECTUS_STATIC_TOKEN` set) → 2. PostgreSQL → 3. `messages/*.json` → 4. Hardcoded defaults
All external calls (Directus, n8n, Redis) must have try/catch with graceful null fallback — the site must never crash if a service is down.
## When implementing a feature
1. Read `lib/directus.ts` to check for existing GraphQL query patterns
2. Add GraphQL query + TypeScript types to `lib/directus.ts` for new Directus collections
3. All POST/PUT endpoints need input validation
4. n8n proxies need rate limiting and 10s timeout
5. Error logging: `if (process.env.NODE_ENV === "development") console.error(...)`
6. Run `npm run build` to verify TypeScript compiles without errors
7. After schema changes, run `npm run db:generate`
## Directus collections
`tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
Locale mapping: `en``en-US`, `de``de-DE`

View File

@@ -0,0 +1,52 @@
---
name: code-reviewer
description: Expert code reviewer for this portfolio. Use proactively immediately after writing or modifying code. Reviews for SSR safety, accessibility contrast, TypeScript strictness, graceful fallbacks, and Conventional Commits.
tools: Read, Grep, Glob, Bash
model: inherit
---
You are a senior code reviewer for Dennis Konkol's portfolio (dk0.dev). You are read-only — you report issues but do not fix them.
## When invoked
1. Run `git diff HEAD` to see all recent changes
2. For each modified file, read it fully before commenting
3. Begin your review immediately — no clarifying questions
## Review checklist
### SSR Safety (critical)
- [ ] No `initial={{ opacity: 0 }}` on server-rendered elements (use `ScrollFadeIn` instead)
- [ ] No bare `window`/`document`/`localStorage` outside `useEffect` or `hasMounted` check
- [ ] `"use client"` directive present on components using hooks or browser APIs
### TypeScript
- [ ] No `any` types — use interfaces from `lib/directus.ts` or `types/`
- [ ] Async components properly typed
### API Routes
- [ ] `export const runtime = 'nodejs'` and `dynamic = 'force-dynamic'` present
- [ ] `source` field in JSON response (`"directus"` | `"fallback"` | `"error"`)
- [ ] Try/catch with graceful fallback on all external calls
- [ ] Error logging behind `process.env.NODE_ENV === "development"` guard
### Design System
- [ ] Only `liquid-*` color tokens used, no hardcoded colors
- [ ] Body text uses `text-stone-600 dark:text-stone-400` (not `text-stone-400` alone)
- [ ] New async components have a Skeleton loading state
### i18n
- [ ] New user-facing strings added to both `messages/en.json` AND `messages/de.json`
- [ ] Server components use `getTranslations()`, client components use `useTranslations()`
### General
- [ ] No `console.error` outside dev guard
- [ ] No emojis in code
- [ ] Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`)
## Output format
Group findings by severity:
- **Critical** — must fix before merge (SSR invisibility, security, crashes)
- **Warning** — should fix (TypeScript issues, missing fallbacks)
- **Suggestion** — nice to have
Include file path, line number, and concrete fix example for each issue.

View File

@@ -0,0 +1,48 @@
---
name: debugger
description: Debugging specialist for this portfolio. Use proactively when encountering build errors, test failures, hydration mismatches, invisible content, or any unexpected behavior. Specializes in Next.js SSR issues, Prisma connection errors, and Docker deployment failures.
tools: Read, Edit, Bash, Grep, Glob
model: opus
---
You are an expert debugger for Dennis Konkol's portfolio (dk0.dev). You specialize in root cause analysis — fix the cause, not the symptom.
## Common issue categories for this project
### Invisible/hidden content
- Check for `initial={{ opacity: 0 }}` on SSR-rendered Framer Motion elements
- Check if `ScrollFadeIn` `hasMounted` guard is working (component renders with styles before mount)
- Check for CSS specificity issues with Tailwind dark mode
### Hydration mismatches
- Look for `typeof window !== "undefined"` checks used incorrectly
- Check if server/client rendered different HTML (dates, random values, user state)
- Look for missing `suppressHydrationWarning` on elements with intentional server/client differences
### Build failures
- Check TypeScript errors: `npm run build` for full output
- Check for missing `"use client"` on components using hooks
- Check for circular imports
### Test failures
- Check if new ESM packages need to be added to `transformIgnorePatterns` in `jest.config.ts`
- Verify mocks in `jest.setup.ts` match what the component expects
- For server component tests, use `const resolved = await Component(props); render(resolved)`
### Database issues
- Prisma client regeneration: `npm run db:generate`
- Check `DATABASE_URL` in `.env.local`
- `prisma db push` for schema sync (development only)
### Docker/deployment issues
- Standalone build required: verify `output: "standalone"` in `next.config.ts`
- Check `scripts/start-with-migrate.js` entrypoint logs
- Dev and production share PostgreSQL and Redis — check for migration conflicts
## Debugging process
1. Read the full error including stack trace
2. Run `git log --oneline -5` and `git diff HEAD~1` to check recent changes
3. Form a hypothesis before touching any code
4. Make the minimal fix that addresses the root cause
5. Verify: `npm run build && npm run test`
6. Explain: root cause, fix applied, prevention strategy

View File

@@ -0,0 +1,39 @@
---
name: frontend-dev
description: Frontend React/Next.js developer for this portfolio. Use proactively when implementing UI components, pages, scroll animations, or anything in app/components/ or app/[locale]/. Expert in Tailwind liquid-* tokens, Framer Motion, next-intl, and SSR safety.
tools: Read, Edit, Write, Bash, Grep, Glob
model: sonnet
permissionMode: acceptEdits
---
You are a senior frontend developer for Dennis Konkol's portfolio (dk0.dev).
## Stack you own
- **Next.js 15 App Router** with React 19 and TypeScript (strict — no `any`)
- **Tailwind CSS** using `liquid-*` color tokens only: `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`, `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
- **Framer Motion 12** — variants pattern with `staggerContainer` + `fadeInUp`
- **next-intl** for i18n (always add keys to both `messages/en.json` and `messages/de.json`)
- **next-themes** for dark mode support
## File ownership
`app/components/`, `app/_ui/`, `app/[locale]/`, `messages/`
## Design rules
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm border-2 rounded-xl`
- Headlines: uppercase, `tracking-tighter`, accent dot at end: `<span className="text-emerald-600">.</span>`
- Body text: `text-stone-600 dark:text-stone-400` — minimum contrast 4.5:1 (never use `text-stone-400` alone)
- Layout: Bento Grid, no floating overlays
- Every async component must have a Skeleton loading state
## SSR animation safety (critical)
**Never** use `initial={{ opacity: 0 }}` on SSR-rendered elements — it bakes invisible HTML.
Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) for scroll animations instead.
`AnimatePresence` is fine only for modals/overlays (client-only).
## When implementing a feature
1. Read existing similar components first with Grep before writing new code
2. Client components need `"use client"` directive
3. Server components use `getTranslations()` from `next-intl/server`; client components use `useTranslations()`
4. New client sections must get a wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider`
5. Add to `app/_ui/HomePageServer.tsx` wrapped in `<ScrollFadeIn>`
6. Run `npm run lint` before finishing — 0 errors required

49
.claude/agents/tester.md Normal file
View File

@@ -0,0 +1,49 @@
---
name: tester
description: Test automation specialist for this portfolio. Use proactively after implementing any feature or bug fix to write Jest unit tests and Playwright E2E tests. Knows all JSDOM quirks and mock patterns specific to this project.
tools: Read, Edit, Write, Bash, Grep, Glob
model: sonnet
---
You are a test automation engineer for Dennis Konkol's portfolio (dk0.dev).
## Test stack
- **Jest** with JSDOM for unit/integration tests (`npm run test`)
- **Playwright** for E2E tests (`npm run test:e2e`)
- **@testing-library/react** for component rendering
## Known mock setup (in jest.setup.ts)
These are already mocked globally — do NOT re-mock them in individual tests:
- `window.matchMedia`
- `window.IntersectionObserver`
- `NextResponse.json`
- `Headers`, `Request`, `Response` (polyfilled from node-fetch)
Test env vars pre-set: `DIRECTUS_URL=http://localhost:8055`, `NEXT_PUBLIC_SITE_URL=http://localhost:3000`
## ESM gotcha
If adding new ESM-only packages to tests, check `transformIgnorePatterns` in `jest.config.ts` — packages like `react-markdown` and `remark-*` need to be listed there.
## Server component test pattern
```typescript
const resolved = await MyServerComponent({ locale: 'en' })
render(resolved)
```
## `next/image` in tests
Use a simple `<img>` with `eslint-disable-next-line @next/next/no-img-element` — don't try to mock next/image.
## When writing tests
1. Read the component/function being tested first
2. Identify: happy path, error path, edge cases, SSR rendering
3. Mock ALL external API calls (Directus, n8n, PostgreSQL)
4. Run `npx jest path/to/test.tsx` to verify the specific test passes
5. Run `npm run test` to verify no regressions
6. Report final coverage for the new code
## File ownership
`__tests__/`, `app/**/__tests__/`, `e2e/`, `jest.config.ts`, `jest.setup.ts`
## E2E test files
`e2e/critical-paths.spec.ts`, `e2e/hydration.spec.ts`, `e2e/accessibility.spec.ts`, `e2e/performance.spec.ts`
Run specific: `npm run test:critical`, `npm run test:hydration`, `npm run test:accessibility`

View File

@@ -0,0 +1,35 @@
---
paths:
- "app/api/**/*.ts"
---
# API Route Rules
Every API route in this project must follow these conventions:
## Required exports
```typescript
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
```
## Response format
All responses must include a `source` field:
```typescript
return NextResponse.json({ data: ..., source: 'directus' | 'fallback' | 'error' })
```
## Error handling
- Wrap all external calls (Directus, n8n, Redis, PostgreSQL) in try/catch
- Return graceful fallback data on failure — never let an external service crash the page
- Error logging: `if (process.env.NODE_ENV === "development") console.error(...)`
## n8n proxies (app/api/n8n/)
- Rate limiting required on all public endpoints (use `lib/auth.ts`)
- 10 second timeout on upstream n8n calls
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
## Directus queries
- Use `directusRequest()` from `lib/directus.ts`
- 2 second timeout is already set in `directusRequest()`
- Always have a hardcoded fallback when Directus returns null

View File

@@ -0,0 +1,37 @@
---
paths:
- "app/components/**/*.tsx"
- "app/_ui/**/*.tsx"
---
# Component Rules
## SSR animation safety (critical)
**Never** use `initial={{ opacity: 0 }}` on server-rendered elements.
This bakes `style="opacity:0"` into HTML — content is invisible if hydration fails.
Use `ScrollFadeIn` instead:
```tsx
import ScrollFadeIn from "@/app/components/ScrollFadeIn"
<ScrollFadeIn><MyComponent /></ScrollFadeIn>
```
`AnimatePresence` is fine for modals and overlays that only appear after user interaction.
## Design system
- Colors: only `liquid-*` tokens — no hardcoded hex or raw Tailwind palette colors
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl`
- Headlines: `uppercase tracking-tighter` with accent dot `<span className="text-emerald-600">.</span>`
- Body text: `text-stone-600 dark:text-stone-400` — never `text-stone-400` alone (fails contrast)
## Async components
Every component that fetches data must have a Skeleton loading state shown while data loads.
## i18n
- Client: `useTranslations("namespace")` from `next-intl`
- Server: `getTranslations("namespace")` from `next-intl/server`
- New client sections need a wrapper in `ClientWrappers.tsx` with scoped `NextIntlClientProvider`
## TypeScript
- No `any` — define interfaces in `lib/directus.ts` or `types/`
- No emojis in code

38
.claude/rules/testing.md Normal file
View File

@@ -0,0 +1,38 @@
---
paths:
- "**/__tests__/**/*.ts"
- "**/__tests__/**/*.tsx"
- "**/*.test.ts"
- "**/*.test.tsx"
- "e2e/**/*.spec.ts"
---
# Testing Rules
## Jest environment
- Global mocks are set up in `jest.setup.ts` — do NOT re-mock `matchMedia`, `IntersectionObserver`, or `NextResponse` in individual tests
- Test env vars are pre-set: `DIRECTUS_URL`, `NEXT_PUBLIC_SITE_URL`
- Always mock external API calls (Directus, n8n, PostgreSQL) — tests must work without running services
## ESM modules
If a new import causes "Must use import to load ES Module" errors, add the package to `transformIgnorePatterns` in `jest.config.ts`.
## Server component tests
```typescript
// Server components return JSX, not a promise in React 19, but async ones need await
const resolved = await MyServerComponent({ locale: 'en', ...props })
render(resolved)
```
## next/image in tests
Replace `next/image` with a plain `<img>` in test renders:
```tsx
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} />
```
## Run commands
- Single file: `npx jest path/to/test.tsx`
- All unit tests: `npm run test`
- Watch mode: `npm run test:watch`
- Specific E2E: `npm run test:critical`, `npm run test:hydration`, `npm run test:accessibility`

25
.claude/settings.json Normal file
View File

@@ -0,0 +1,25 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "FILE=$(echo $CLAUDE_TOOL_INPUT | jq -r '.file_path // empty'); if [ -n \"$FILE\" ] && echo \"$FILE\" | grep -qE '\\.(ts|tsx|js|jsx)$'; then npx eslint --fix \"$FILE\" 2>/dev/null || true; fi"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "osascript -e 'display notification \"Claude ist fertig\" with title \"Claude Code\" sound name \"Glass\"' 2>/dev/null || true"
}
]
}
]
}
}

View File

@@ -0,0 +1,50 @@
---
name: add-section
description: Orchestrate adding a new CMS-managed section to the portfolio following the full 6-step pattern
context: fork
agent: general-purpose
---
Add a new CMS-managed section called "$ARGUMENTS" to the portfolio.
Follow the exact 6-step pattern from CLAUDE.md:
**Step 1 — lib/directus.ts**
Read `lib/directus.ts` first, then add:
- TypeScript interface for the new collection
- `directusRequest()` GraphQL query for the collection (with translation support if needed)
- Export the fetch function
**Step 2 — API Route**
Create `app/api/$ARGUMENTS/route.ts`:
- `export const runtime = 'nodejs'`
- `export const dynamic = 'force-dynamic'`
- Try Directus first, fallback to hardcoded defaults
- Include `source: "directus" | "fallback" | "error"` in response
- Error logging behind `process.env.NODE_ENV === "development"` guard
**Step 3 — Component**
Create `app/components/$ARGUMENTS.tsx`:
- `"use client"` directive
- Skeleton loading state for the async data
- Tailwind liquid-* tokens for styling (cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15 backdrop-blur-sm border-2 rounded-xl`)
- Headline uppercase with tracking-tighter and emerald accent dot
**Step 4 — i18n**
Add translation keys to both:
- `messages/en.json`
- `messages/de.json`
**Step 5 — Client Wrapper**
Add `${ARGUMENTS}Client` to `app/components/ClientWrappers.tsx`:
- Wrap in scoped `NextIntlClientProvider` with only the needed translation namespace
**Step 6 — Homepage Integration**
Add to `app/_ui/HomePageServer.tsx`:
- Fetch translations in the existing `Promise.all`
- Render wrapped in `<ScrollFadeIn>`
After implementation:
- Run `npm run lint` — must be 0 errors
- Run `npm run build` — must compile successfully
- Report what was created and any manual steps remaining (e.g., creating the Directus collection)

View File

@@ -0,0 +1,39 @@
---
name: check-quality
description: Run all quality checks (lint, build, tests) and report a summary of the project's health
disable-model-invocation: false
---
Run all quality checks for this portfolio project and report the results.
Execute these checks in order:
**1. ESLint**
Run: `npm run lint`
Required: 0 errors (warnings OK)
**2. TypeScript**
Run: `npx tsc --noEmit`
Required: 0 type errors
**3. Unit Tests**
Run: `npm run test -- --passWithNoTests`
Report: pass/fail count and any failing test names
**4. Production Build**
Run: `npm run build`
Required: successful completion
**5. i18n Parity Check**
Compare keys in `messages/en.json` vs `messages/de.json` — report any keys present in one but not the other.
After all checks, produce a summary table:
| Check | Status | Details |
|-------|--------|---------|
| ESLint | ✓/✗ | ... |
| TypeScript | ✓/✗ | ... |
| Tests | ✓/✗ | X passed, Y failed |
| Build | ✓/✗ | ... |
| i18n parity | ✓/✗ | Missing keys: ... |
If anything fails, provide the specific error and a recommended fix.

View File

@@ -0,0 +1,30 @@
---
name: review-changes
description: Run a thorough code review on all recent uncommitted changes using the code-reviewer agent
context: fork
agent: code-reviewer
---
Review all recent changes in this repository.
First gather context:
- Recent changes: !`git diff HEAD`
- Staged changes: !`git diff --cached`
- Modified files: !`git status --short`
- Recent commits: !`git log --oneline -5`
Then perform a full code review using the code-reviewer agent checklist:
- SSR safety (no `initial={{ opacity: 0 }}` on server elements)
- TypeScript strictness (no `any`)
- API route conventions (`runtime`, `dynamic`, `source` field)
- Design system compliance (liquid-* tokens, contrast ratios)
- i18n completeness (both en.json and de.json)
- Error logging guards
- Graceful fallbacks on all external calls
Output:
- **Critical** issues (must fix before merge)
- **Warnings** (should fix)
- **Suggestions** (nice to have)
Include file:line references and concrete fix examples for each issue.

View File

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

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

@@ -0,0 +1,279 @@
name: CI / CD
on:
push:
branches: [main, dev, production]
pull_request:
branches: [main, dev, production]
env:
NODE_VERSION: '25'
DOCKER_IMAGE: portfolio-app
jobs:
# ── Job 1: Lint, Test, Build (runs on every push/PR) ──
test-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Type check
run: npx tsc --noEmit
# ── Job 2: Deploy to dev (only on dev branch, after tests pass) ──
deploy-dev:
needs: test-build
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: |
echo "🏗️ Building dev Docker image..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:dev \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
-t ${{ env.DOCKER_IMAGE }}:dev \
.
echo "✅ Docker image built successfully"
- name: Deploy dev container
run: |
echo "🚀 Starting dev deployment..."
CONTAINER_NAME="portfolio-app-dev"
HEALTH_PORT="3001"
IMAGE_NAME="${{ env.DOCKER_IMAGE }}:dev"
# Check for existing container
EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "")
# Ensure networks exist
echo "🌐 Ensuring networks exist..."
docker network create portfolio_net 2>/dev/null || true
docker network create proxy 2>/dev/null || true
# Verify production DB is reachable
if docker exec portfolio-postgres pg_isready -U portfolio_user -d portfolio_db >/dev/null 2>&1; then
echo "✅ Production database is ready!"
else
echo "⚠️ Production database not reachable, app will use fallbacks"
fi
# Stop and remove existing container
if [ ! -z "$EXISTING_CONTAINER" ]; then
echo "🛑 Stopping existing container..."
docker stop $EXISTING_CONTAINER 2>/dev/null || true
docker rm $EXISTING_CONTAINER 2>/dev/null || true
sleep 3
fi
# Ensure port is free
PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->)" | awk '{print $1}' | head -1 || echo "")
if [ ! -z "$PORT_CONTAINER" ]; then
echo "⚠️ Port ${HEALTH_PORT} still in use, freeing..."
docker stop $PORT_CONTAINER 2>/dev/null || true
docker rm $PORT_CONTAINER 2>/dev/null || true
sleep 3
fi
# Start new container
echo "🆕 Starting new dev container..."
docker run -d \
--name $CONTAINER_NAME \
--restart unless-stopped \
--network portfolio_net \
-p ${HEALTH_PORT}:3000 \
-e NODE_ENV=production \
-e LOG_LEVEL=${LOG_LEVEL:-debug} \
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \
-e DATABASE_URL="${DATABASE_URL}" \
-e REDIS_URL="${REDIS_URL}" \
-e MY_EMAIL="${MY_EMAIL}" \
-e MY_INFO_EMAIL="${MY_INFO_EMAIL}" \
-e MY_PASSWORD="${MY_PASSWORD}" \
-e MY_INFO_PASSWORD="${MY_INFO_PASSWORD}" \
-e ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}" \
-e ADMIN_SESSION_SECRET="${ADMIN_SESSION_SECRET}" \
-e N8N_WEBHOOK_URL="${N8N_WEBHOOK_URL}" \
-e N8N_SECRET_TOKEN="${N8N_SECRET_TOKEN}" \
-e N8N_API_KEY="${N8N_API_KEY}" \
-e DIRECTUS_URL="${DIRECTUS_URL}" \
-e DIRECTUS_STATIC_TOKEN="${DIRECTUS_STATIC_TOKEN}" \
$IMAGE_NAME
# Connect to proxy network
docker network connect proxy $CONTAINER_NAME 2>/dev/null || true
# Wait for health
echo "⏳ Waiting for container to be healthy..."
for i in {1..60}; do
if curl -f -s http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "✅ Dev container is healthy!"
break
fi
HEALTH=$(docker inspect $CONTAINER_NAME --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo "✅ Docker health check passed!"
break
fi
if [ $i -eq 60 ]; then
echo "⚠️ Health check timed out, showing logs:"
docker logs $CONTAINER_NAME --tail=30
fi
sleep 2
done
echo "✅ Dev deployment completed!"
env:
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
DATABASE_URL: postgresql://portfolio_user:portfolio_pass@portfolio-postgres:5432/portfolio_db?schema=public
REDIS_URL: redis://portfolio-redis:6379
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}
DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}
- name: Cleanup
run: docker image prune -f
# ── Job 3: Deploy to production (only on production branch, after tests pass) ──
deploy-production:
needs: test-build
if: github.ref == 'refs/heads/production' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: |
echo "🏗️ Building production Docker image..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:production \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
-t ${{ env.DOCKER_IMAGE }}:production \
-t ${{ env.DOCKER_IMAGE }}:latest \
.
echo "✅ Docker image built successfully"
- name: Deploy production container
run: |
echo "🚀 Starting production deployment..."
COMPOSE_FILE="docker-compose.production.yml"
CONTAINER_NAME="portfolio-app"
HEALTH_PORT="3000"
# Backup current container ID
OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "")
# Ensure network exists
docker network create portfolio_net 2>/dev/null || true
# Export variables for docker-compose
export N8N_WEBHOOK_URL="${N8N_WEBHOOK_URL}"
export N8N_SECRET_TOKEN="${N8N_SECRET_TOKEN}"
export N8N_API_KEY="${N8N_API_KEY}"
export MY_EMAIL="${MY_EMAIL}"
export MY_INFO_EMAIL="${MY_INFO_EMAIL}"
export MY_PASSWORD="${MY_PASSWORD}"
export MY_INFO_PASSWORD="${MY_INFO_PASSWORD}"
export ADMIN_BASIC_AUTH="${ADMIN_BASIC_AUTH}"
export ADMIN_SESSION_SECRET="${ADMIN_SESSION_SECRET}"
export DIRECTUS_URL="${DIRECTUS_URL}"
export DIRECTUS_STATIC_TOKEN="${DIRECTUS_STATIC_TOKEN}"
# Start new container via compose
echo "🆕 Starting new production container..."
docker compose -f $COMPOSE_FILE up -d portfolio
# Wait for health
echo "⏳ Waiting for container to be healthy..."
HEALTH_CHECK_PASSED=false
for i in {1..90}; do
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo "✅ Production container is healthy!"
HEALTH_CHECK_PASSED=true
break
fi
if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "✅ Production HTTP health check passed!"
HEALTH_CHECK_PASSED=true
break
fi
fi
if [ $((i % 15)) -eq 0 ]; then
echo "📊 Health: ${HEALTH:-unknown} (attempt $i/90)"
docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true
fi
sleep 2
done
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
echo "❌ Production health check failed!"
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || true
exit 1
fi
# Remove old container if different
if [ ! -z "$OLD_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
echo "🧹 Removing old container..."
docker stop $OLD_CONTAINER 2>/dev/null || true
docker rm $OLD_CONTAINER 2>/dev/null || true
fi
fi
echo "✅ Production deployment completed!"
env:
NODE_ENV: production
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
DIRECTUS_URL: ${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}
DIRECTUS_STATIC_TOKEN: ${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}
- name: Cleanup
run: docker image prune -f

View File

@@ -1,314 +0,0 @@
name: Dev Deployment (Zero Downtime)
on:
push:
branches: [ dev ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: dev
jobs:
deploy-dev:
runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
continue-on-error: true # Don't block dev deployments on lint errors
- name: Run tests
run: npm run test
continue-on-error: true # Don't block dev deployments on test failures
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building dev Docker image with BuildKit cache..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
.
echo "✅ Docker image built successfully"
- name: Zero-Downtime Dev Deployment
run: |
echo "🚀 Starting zero-downtime dev deployment..."
CONTAINER_NAME="portfolio-app-dev"
HEALTH_PORT="3001"
IMAGE_NAME="${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}"
# Check for existing container (running or stopped)
EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "")
# Start DB and Redis if not running
echo "🗄️ Starting database and Redis..."
COMPOSE_FILE="docker-compose.dev.minimal.yml"
# Stop and remove existing containers to ensure clean start with correct architecture
echo "🧹 Cleaning up existing containers..."
docker stop portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true
docker rm portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true
# Remove old images to force re-pull with correct architecture
echo "🔄 Removing old images to force re-pull..."
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true
# Pull images with correct architecture (Docker will auto-detect)
echo "📥 Pulling images for current architecture..."
docker compose -f $COMPOSE_FILE pull postgres redis
# Start containers
echo "📦 Starting PostgreSQL and Redis containers..."
docker compose -f $COMPOSE_FILE up -d postgres redis
# Wait for DB to be ready
echo "⏳ Waiting for database to be ready..."
for i in {1..30}; do
if docker exec portfolio_postgres_dev pg_isready -U portfolio_user -d portfolio_dev >/dev/null 2>&1; then
echo "✅ Database is ready!"
break
fi
echo "⏳ Waiting for database... ($i/30)"
sleep 1
done
# Export environment variables
export NODE_ENV=production
export LOG_LEVEL=${LOG_LEVEL:-debug}
export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev}
export DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public"
export REDIS_URL="redis://portfolio_redis_dev:6379"
export MY_EMAIL=${MY_EMAIL}
export MY_INFO_EMAIL=${MY_INFO_EMAIL}
export MY_PASSWORD=${MY_PASSWORD}
export MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
export ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH}
export ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
export N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''}
export N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''}
export PORT=${HEALTH_PORT}
# Stop and remove existing container if it exists (running or stopped)
if [ ! -z "$EXISTING_CONTAINER" ]; then
echo "🛑 Stopping and removing existing container..."
docker stop $EXISTING_CONTAINER 2>/dev/null || true
docker rm $EXISTING_CONTAINER 2>/dev/null || true
echo "✅ Old container removed"
# Wait for Docker to release the port
echo "⏳ Waiting for Docker to release port ${HEALTH_PORT}..."
sleep 3
fi
# Check if port is still in use by Docker containers (check all containers, not just running)
PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "")
if [ ! -z "$PORT_CONTAINER" ]; then
echo "⚠️ Port ${HEALTH_PORT} is still in use by container $PORT_CONTAINER"
echo "🛑 Stopping and removing container using port..."
docker stop $PORT_CONTAINER 2>/dev/null || true
docker rm $PORT_CONTAINER 2>/dev/null || true
sleep 3
fi
# Also check for any containers with the same name that might be using the port
SAME_NAME_CONTAINER=$(docker ps -a -q -f name=$CONTAINER_NAME | head -1 || echo "")
if [ ! -z "$SAME_NAME_CONTAINER" ] && [ "$SAME_NAME_CONTAINER" != "$EXISTING_CONTAINER" ]; then
echo "⚠️ Found another container with same name: $SAME_NAME_CONTAINER"
docker stop $SAME_NAME_CONTAINER 2>/dev/null || true
docker rm $SAME_NAME_CONTAINER 2>/dev/null || true
sleep 2
fi
# Also check if port is in use by another process (non-Docker)
PORT_IN_USE=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || ss -tlnp | grep ":${HEALTH_PORT} " | head -1 || echo "")
if [ ! -z "$PORT_IN_USE" ] && [ -z "$PORT_CONTAINER" ]; then
echo "⚠️ Port ${HEALTH_PORT} is in use by process"
echo "Attempting to free the port..."
# Try to find and kill the process
if command -v lsof >/dev/null 2>&1; then
PID=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "")
if [ ! -z "$PID" ]; then
kill -9 $PID 2>/dev/null || true
sleep 2
fi
fi
fi
# Final check: verify port is free and wait if needed
echo "🔍 Verifying port ${HEALTH_PORT} is free..."
MAX_WAIT=10
WAIT_COUNT=0
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "")
if [ -z "$PORT_CHECK" ]; then
# Also check with lsof/ss if available
if command -v lsof >/dev/null 2>&1; then
PORT_CHECK=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "")
elif command -v ss >/dev/null 2>&1; then
PORT_CHECK=$(ss -tlnp | grep ":${HEALTH_PORT} " || echo "")
fi
fi
if [ -z "$PORT_CHECK" ]; then
echo "✅ Port ${HEALTH_PORT} is free!"
break
fi
WAIT_COUNT=$((WAIT_COUNT + 1))
echo "⏳ Port still in use, waiting... ($WAIT_COUNT/$MAX_WAIT)"
sleep 1
done
# If port is still in use, try alternative port
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
echo "⚠️ Port ${HEALTH_PORT} is still in use after waiting. Trying alternative port..."
HEALTH_PORT="3002"
echo "🔄 Using alternative port: ${HEALTH_PORT}"
# Quick check if alternative port is also in use
ALT_PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "")
if [ ! -z "$ALT_PORT_CHECK" ]; then
echo "❌ Alternative port ${HEALTH_PORT} is also in use!"
echo "Attempting to free alternative port..."
ALT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "")
if [ ! -z "$ALT_CONTAINER" ]; then
docker stop $ALT_CONTAINER 2>/dev/null || true
docker rm $ALT_CONTAINER 2>/dev/null || true
sleep 2
fi
fi
fi
# 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 \
--name $CONTAINER_NAME \
--restart unless-stopped \
--network portfolio_dev \
-p ${HEALTH_PORT}:3000 \
-e NODE_ENV=production \
-e LOG_LEVEL=${LOG_LEVEL:-debug} \
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \
-e DATABASE_URL=${DATABASE_URL} \
-e REDIS_URL=${REDIS_URL} \
-e MY_EMAIL=${MY_EMAIL} \
-e MY_INFO_EMAIL=${MY_INFO_EMAIL} \
-e MY_PASSWORD=${MY_PASSWORD} \
-e MY_INFO_PASSWORD=${MY_INFO_PASSWORD} \
-e ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} \
-e ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} \
-e N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} \
-e N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} \
$IMAGE_NAME
# Connect container to proxy network as well (for external access)
echo "🔗 Connecting container to proxy network..."
docker network connect proxy $CONTAINER_NAME 2>/dev/null || echo "Container might already be connected to proxy network"
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
HEALTH_CHECK_PASSED=false
for i in {1..60}; do
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
if [ ! -z "$NEW_CONTAINER" ]; then
# Check Docker health status
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo "✅ New container is healthy!"
HEALTH_CHECK_PASSED=true
break
fi
# Also check HTTP health endpoint
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "✅ New container is responding!"
HEALTH_CHECK_PASSED=true
break
fi
fi
echo "⏳ Waiting... ($i/60)"
sleep 2
done
# Verify new container is working
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
docker logs $CONTAINER_NAME --tail=50
fi
# Remove old container if it exists and is different
if [ ! -z "$OLD_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
echo "🧹 Removing old container..."
docker stop $OLD_CONTAINER 2>/dev/null || true
docker rm $OLD_CONTAINER 2>/dev/null || true
fi
fi
echo "✅ Dev deployment completed!"
env:
NODE_ENV: production
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
DATABASE_URL: postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public
REDIS_URL: redis://portfolio_redis_dev:6379
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
- name: Dev Health Check
run: |
echo "🔍 Running dev health checks..."
for i in {1..20}; do
if curl -f http://localhost:3001/api/health && curl -f http://localhost:3001/ > /dev/null; then
echo "✅ Dev is fully operational!"
exit 0
fi
echo "⏳ Waiting for dev... ($i/20)"
sleep 3
done
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
docker logs portfolio-app-dev --tail=50
- name: Cleanup
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Cleanup completed"

View File

@@ -1,275 +0,0 @@
name: Production Deployment (Zero Downtime)
on:
push:
branches: [ production ]
env:
NODE_VERSION: '20'
DOCKER_IMAGE: portfolio-app
IMAGE_TAG: production
jobs:
deploy-production:
runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting and tests in parallel
run: |
npm run lint &
LINT_PID=$!
npm run test:production &
TEST_PID=$!
wait $LINT_PID $TEST_PID
- name: Build application
run: npm run build
- name: Build Docker image
run: |
echo "🏗️ Building production Docker image with BuildKit cache..."
DOCKER_BUILDKIT=1 docker build \
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
-t ${{ env.DOCKER_IMAGE }}:latest \
.
echo "✅ Docker image built successfully"
- name: Zero-Downtime Production Deployment
run: |
echo "🚀 Starting zero-downtime production deployment..."
COMPOSE_FILE="docker-compose.production.yml"
CONTAINER_NAME="portfolio-app"
HEALTH_PORT="3000"
# Backup current container ID if running (exact name match to avoid staging)
OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "")
# Export environment variables for docker-compose
export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}"
export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}"
export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}"
# Also export other variables that docker-compose needs
export MY_EMAIL="${{ vars.MY_EMAIL }}"
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}"
# 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
# Wait for new container to be healthy
echo "⏳ Waiting for new container to be healthy..."
HEALTH_CHECK_PASSED=false
for i in {1..90}; do
# Get the production container ID (exact name match, exclude staging)
# Use compose project to ensure we get the right container
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
# Fallback: try exact name match with leading slash
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
# Verify it's actually the production container by checking compose project label
CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "")
CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "")
if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then
# Check Docker health status first (most reliable)
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
if [ "$HEALTH" == "healthy" ]; then
echo "✅ New container is healthy (Docker health check)!"
# Also verify HTTP endpoint from inside container
if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Container HTTP endpoint is also responding!"
HEALTH_CHECK_PASSED=true
break
else
echo "⚠️ Docker health check passed, but HTTP endpoint test failed. Continuing..."
fi
fi
# Try HTTP health endpoint from host (may not work if port not mapped yet)
if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
echo "✅ New container is responding to HTTP health check from host!"
HEALTH_CHECK_PASSED=true
break
fi
# Show container status for debugging
if [ $((i % 10)) -eq 0 ]; then
echo "📊 Container ID: $NEW_CONTAINER"
echo "📊 Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')"
echo "📊 Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')"
echo "📊 Health status: $HEALTH"
echo "📊 Testing from inside container:"
docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed"
docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true
fi
else
echo "⚠️ Found container but it's not from production compose file (skipping): $NEW_CONTAINER"
fi
fi
echo "⏳ Waiting... ($i/90)"
sleep 2
done
# Final verification: Check Docker health status (most reliable)
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
if [ "$FINAL_HEALTH" == "healthy" ]; then
echo "✅ Final verification: Container is healthy!"
HEALTH_CHECK_PASSED=true
fi
fi
# Verify new container is working
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
echo "❌ New container failed health check!"
echo "📋 All running containers with 'portfolio' in name:"
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"
echo "📋 Production container from compose:"
docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose"
echo "📋 Container logs:"
docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs"
# Get the correct container ID
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ]; then
echo "📋 Container inspect (ID: $NEW_CONTAINER):"
docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found"
echo "📋 Testing health endpoint from inside container:"
docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
# Check Docker health status - if it's healthy, accept it
FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then
echo "✅ Docker health check reports healthy - accepting deployment!"
HEALTH_CHECK_PASSED=true
else
echo "❌ Docker health check also reports: $FINAL_HEALTH_CHECK"
exit 1
fi
else
echo "⚠️ Could not find production container!"
exit 1
fi
fi
# Remove old container if it exists and is different
if [ ! -z "$OLD_CONTAINER" ]; then
# Get the new production container ID
NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1)
if [ -z "$NEW_CONTAINER" ]; then
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
echo "🧹 Removing old container..."
docker stop $OLD_CONTAINER 2>/dev/null || true
docker rm $OLD_CONTAINER 2>/dev/null || true
fi
fi
echo "✅ Production deployment completed with zero downtime!"
env:
NODE_ENV: production
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
MY_EMAIL: ${{ vars.MY_EMAIL }}
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
- name: Production Health Check
run: |
echo "🔍 Running production health checks..."
COMPOSE_FILE="docker-compose.production.yml"
CONTAINER_NAME="portfolio-app"
# Get the production container ID
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
if [ -z "$CONTAINER_ID" ]; then
CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
fi
if [ -z "$CONTAINER_ID" ]; then
echo "❌ Production container not found!"
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}"
exit 1
fi
echo "📦 Found container: $CONTAINER_ID"
# Wait for container to be healthy (using Docker's health check)
HEALTH_CHECK_PASSED=false
for i in {1..30}; do
HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown")
if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then
echo "✅ Container is healthy and running!"
# Test from inside the container (most reliable)
if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
echo "✅ Health endpoint responds from inside container!"
HEALTH_CHECK_PASSED=true
break
else
echo "⚠️ Container is healthy but HTTP endpoint test failed. Retrying..."
fi
fi
if [ $((i % 5)) -eq 0 ]; then
echo "📊 Status: $STATUS, Health: $HEALTH (attempt $i/30)"
fi
echo "⏳ Waiting for production... ($i/30)"
sleep 2
done
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
echo "❌ Production health check failed!"
echo "📋 Container status:"
docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container"
echo "📋 Container logs:"
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs"
echo "📋 Testing from inside container:"
docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
exit 1
fi
echo "✅ Production is fully operational!"
- name: Cleanup
run: |
echo "🧹 Cleaning up old images..."
docker image prune -f
echo "✅ Cleanup completed"

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

@@ -0,0 +1,107 @@
# Portfolio Project Instructions
Dennis Konkol's portfolio (dk0.dev) — Next.js 15, Directus CMS, n8n automation, "Liquid Editorial Bento" design system.
## Build, Test, and Lint
```bash
npm run dev:next # Plain Next.js dev server (no Docker)
npm run build # Production build (standalone mode)
npm run lint # ESLint (0 errors required, warnings OK)
npm run lint:fix # Auto-fix lint issues
npm run test # All Jest unit tests
npx jest path/to/test.tsx # Run a single test file
npm run test:watch # Watch mode
npm run test:e2e # Playwright E2E tests
npm run db:generate # Regenerate Prisma client after schema changes
```
## Architecture
### Server/Client Component Split
The homepage uses a **server component orchestrator** pattern:
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps client sections in `ScrollFadeIn`
- `app/components/Hero.tsx`**server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`, each wrapping their component in a scoped `NextIntlClientProvider` with only the needed translation keys
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
### SSR Animation Safety
**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if hydration fails.
Use `ScrollFadeIn` component instead (`app/components/ScrollFadeIn.tsx`): renders no inline style during SSR (content visible by default), applies opacity+transform only after `hasMounted` check, animates via IntersectionObserver + CSS transitions.
Framer Motion `AnimatePresence` is fine for modals/overlays that only render after user interaction.
### Data Source Fallback Chain
Every data fetch degrades gracefully — the site never crashes:
1. **Directus CMS** → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
### CMS Integration (Directus)
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
- Returns `null` on failure (never throws)
- Locale mapping: `en``en-US`, `de``de-DE`
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and return `source` field (`directus|fallback|error`)
### n8n Integration
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
- Auth: `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- All endpoints have rate limiting and 10s timeout
- Hardcover reading data cached 5 minutes
## Key Conventions
### i18n
- Locales: `en`, `de` — defined in `middleware.ts`, must match `app/[locale]/layout.tsx`
- Client components: `useTranslations("key.path")` from `next-intl`
- Server components: `getTranslations("key.path")` from `next-intl/server`
- Always add keys to both `messages/en.json` and `messages/de.json`
### Design System
- Custom Tailwind colors: `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`, `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`
- Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<span className="text-emerald-600">.</span>`)
- Layout: Bento Grid, no floating overlays
- Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400`) for body text — contrast ratio must be ≥4.5:1
### Code Style
- TypeScript: no `any` — use interfaces from `lib/directus.ts` or `types/`
- Error logging: `console.error` only when `process.env.NODE_ENV === "development"`
- File naming: PascalCase components (`About.tsx`), kebab-case API routes (`book-reviews/`), kebab-case lib utils
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
- Every async component needs a Skeleton loading state
### Testing
- Jest with JSDOM; mocks for `window.matchMedia` and `IntersectionObserver` in `jest.setup.ts`
- ESM modules transformed via `transformIgnorePatterns` (react-markdown, remark-*, etc.)
- Server component tests: `const resolved = await Component({ props }); render(resolved)`
- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `<img>` tag
### Docker & Deployment
- `output: "standalone"` in `next.config.ts`
- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal on failure), starts server
- CI/CD: `.gitea/workflows/ci.yml``test-build` job (all branches), `deploy-dev` (dev only), `deploy-production` (production only)
- Branches: `dev` → testing.dk0.dev, `production` → dk0.dev
- Dev and production share the same PostgreSQL and Redis instances
## Common Tasks
### Adding a CMS-managed section
1. Define GraphQL query + types in `lib/directus.ts`
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
3. Create component in `app/components/<Name>.tsx` with Skeleton loading state
4. Add i18n keys to both `messages/en.json` and `messages/de.json`
5. Create a `<Name>Client` wrapper in `ClientWrappers.tsx` with scoped `NextIntlClientProvider`
6. Add to `HomePageServer.tsx` wrapped in `ScrollFadeIn`

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

5
.gitignore vendored
View File

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

135
CLAUDE.md Normal file
View File

@@ -0,0 +1,135 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "Liquid Editorial Bento" design system with soft gradient colors and glassmorphism effects.
## Tech Stack
- **Framework**: Next.js 15 (App Router), TypeScript 5.9, React 19
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
- **Animations**: Framer Motion 12
- **3D**: Three.js + React Three Fiber + `@shadergradient/react` (shader gradient background)
- **Database**: PostgreSQL via Prisma ORM
- **Cache**: Redis (optional)
- **CMS**: Directus (self-hosted, GraphQL only, optional)
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
- **i18n**: next-intl (EN + DE), message files in `messages/`
- **Deployment**: Docker + Nginx, CI via Gitea Actions (`output: "standalone"`)
## 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 (0 errors required, warnings OK)
npm run lint:fix # Auto-fix lint issues
npm run test # All Jest unit tests
npx jest path/to/test.tsx # Run a single test file
npm run test:watch # Watch mode
npm run test:e2e # Playwright E2E tests
npm run db:generate # Regenerate Prisma client after schema changes
```
## Architecture
### Server/Client Component Split
The homepage uses a **server component orchestrator** pattern:
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps below-fold sections in `ScrollFadeIn`
- `app/components/Hero.tsx`**server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`; each wraps its component in a scoped `NextIntlClientProvider` with only the needed translation namespace
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
### SSR Animation Safety
**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if JS hydration fails or is slow.
Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) instead: renders no inline style during SSR, applies opacity+transform only after `hasMounted` check via IntersectionObserver + CSS transitions.
`AnimatePresence` is fine for modals/overlays that only render after user interaction.
### Data Source Fallback Chain
Every data fetch degrades gracefully — the site never crashes:
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured) → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
### CMS Integration (Directus)
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
- Returns `null` on failure, never throws
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
- Translations use Directus native M2O system; locale mapping: `en``en-US`, `de``de-DE`
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and include a `source` field in the response (`"directus"` | `"fallback"` | `"error"`)
### n8n Integration
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- All endpoints have rate limiting and 10s timeout
- Hardcover reading data cached 5 minutes
## Design System
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: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`.
Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<span className="text-emerald-600">.</span>`).
Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400` alone) for body text — contrast ratio must be ≥4.5:1.
## Conventions
- **TypeScript**: No `any` — use interfaces from `lib/directus.ts` or `types/`
- **Components**: PascalCase files in `app/components/`; every async component needs a Skeleton loading state
- **API routes**: kebab-case directories in `app/api/`
- **i18n**: Always add keys to both `messages/en.json` and `messages/de.json`; `useTranslations()` in client, `getTranslations()` in server components
- **Error logging**: `console.error` only when `process.env.NODE_ENV === "development"`
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
- **No emojis** in code unless explicitly requested
## Testing Notes
- Jest with JSDOM; `jest.setup.ts` mocks `window.matchMedia`, `IntersectionObserver`, and `NextResponse`
- ESM modules (react-markdown, remark-*, etc.) handled via `transformIgnorePatterns` in `jest.config.ts`
- Server component tests: `const resolved = await Component({ props }); render(resolved)`
- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `<img>` tag
## Deployment & CI/CD
- `output: "standalone"` in `next.config.ts`
- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal), starts server
- CI/CD: `.gitea/workflows/ci.yml``test-build` (all branches), `deploy-dev` (dev branch only), `deploy-production` (production branch only)
- **Branches**: `dev` → testing.dk0.dev | `production` → dk0.dev
- Dev and production share the same PostgreSQL and Redis instances
## Key Environment Variables
```bash
DIRECTUS_URL=https://cms.dk0.dev
DIRECTUS_STATIC_TOKEN=...
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=...
N8N_API_KEY=...
DATABASE_URL=postgresql://...
REDIS_URL=redis://... # optional
```
## Adding a CMS-managed Section
1. Define GraphQL query + types in `lib/directus.ts`
2. Create API route `app/api/<name>/route.ts` with `runtime='nodejs'`, `dynamic='force-dynamic'`, and `source` field in response
3. Create component `app/components/<Name>.tsx` with Skeleton loading state
4. Add i18n keys to both `messages/en.json` and `messages/de.json`
5. Create `<Name>Client` wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider`
6. Add to `app/_ui/HomePageServer.tsx` wrapped in `<ScrollFadeIn>`

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 # Multi-stage build for optimized production image
FROM node:20 AS base FROM node:25 AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
@@ -31,10 +31,10 @@ RUN npx prisma generate
# Copy source code (this invalidates cache when code changes) # Copy source code (this invalidates cache when code changes)
COPY . . COPY . .
# Build the application # Build the application (mount cache for faster rebuilds)
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production ENV NODE_ENV=production
RUN npm run build RUN --mount=type=cache,target=/app/.next/cache npm run build
# Verify standalone output was created and show structure for debugging # Verify standalone output was created and show structure for debugging
RUN if [ ! -d .next/standalone ]; then \ RUN if [ ! -d .next/standalone ]; then \
@@ -67,10 +67,6 @@ RUN adduser --system --uid 1001 nextjs
# Copy the built application # Copy the built application
COPY --from=builder /app/public ./public 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 # Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing # https://nextjs.org/docs/advanced-features/output-file-tracing
# Copy standalone output (contains server.js and all dependencies) # 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/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 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 Prisma files
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma

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 # Quick links
- **Ops / setup / deployment / testing**: `docs/OPERATIONS.md` - **Ops / setup / deployment / testing**: `docs/OPERATIONS.md`
- **Locale System & Translations**: `docs/LOCALE_SYSTEM.md`
# Dennis Konkol Portfolio - Modern Dark Theme # 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 { NextIntlClientProvider } from "next-intl";
import { setRequestLocale } from "next-intl/server"; import { setRequestLocale } from "next-intl/server";
import React from "react"; import React from "react";
import { notFound } from "next/navigation";
import ConsentBanner from "../components/ConsentBanner"; 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({ export default async function LocaleLayout({
children, children,
params, params,
@@ -11,11 +34,17 @@ export default async function LocaleLayout({
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
}) { }) {
const { locale } = await params; 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. // Ensure next-intl actually uses the route segment locale for this request.
setRequestLocale(locale); setRequestLocale(locale);
// Load messages explicitly by route locale to avoid falling back to the wrong // Load messages explicitly by route locale to avoid falling back to the wrong
// language when request-level locale detection is unavailable/misconfigured. // language when request-level locale detection is unavailable/misconfigured.
const messages = (await import(`../../messages/${locale}.json`)).default; const messages = await loadEnhancedMessages(locale);
return ( return (
<NextIntlClientProvider locale={locale} messages={messages}> <NextIntlClientProvider locale={locale} messages={messages}>

View File

@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import HomePage from "../_ui/HomePage"; import HomePageServer from "../_ui/HomePageServer";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
export async function generateMetadata({ export async function generateMetadata({
@@ -17,7 +17,12 @@ export async function generateMetadata({
}; };
} }
export default function Page() { export default async function Page({
return <HomePage />; 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 { notFound } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjectBySlug } from "@/lib/directus";
import { ProjectDetailData } from "@/app/_ui/ProjectDetailClient";
export const revalidate = 300; export const revalidate = 300;
@@ -12,6 +14,20 @@ export async function generateMetadata({
params: Promise<{ locale: string; slug: string }>; params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { locale, slug } = await params; 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}` }); const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
return { return {
alternates: { alternates: {
@@ -28,26 +44,76 @@ export default async function ProjectPage({
}) { }) {
const { locale, slug } = await params; const { locale, slug } = await params;
const project = await prisma.project.findFirst({ // Try PostgreSQL first
const dbProject = await prisma.project.findFirst({
where: { slug, published: true }, where: { slug, published: true },
include: { include: {
translations: { translations: {
where: { locale }, select: { title: true, description: true, content: true, locale: true },
select: { title: true, description: true },
}, },
}, },
}); });
if (!project) return notFound(); let projectData: ProjectDetailData | null = null;
const tr = project.translations?.[0]; if (dbProject) {
const { translations: _translations, ...rest } = project; const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
const localized = { 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, ...rest,
title: tr?.title ?? project.title, title: tr?.title ?? dbProject.title,
description: tr?.description ?? project.description, 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 { prisma } from "@/lib/prisma";
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient"; import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo"; import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
import { getProjects as getDirectusProjects } from "@/lib/directus";
export const revalidate = 300; export const revalidate = 300;
@@ -27,27 +28,67 @@ export default async function ProjectsPage({
}) { }) {
const { locale } = await params; const { locale } = await params;
const projects = await prisma.project.findMany({ // Fetch from PostgreSQL
const dbProjects = await prisma.project.findMany({
where: { published: true }, where: { published: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { include: {
translations: { translations: {
where: { locale }, select: { title: true, description: true, locale: true },
select: { title: true, description: true },
}, },
}, },
}); });
const localized = projects.map((p) => { // Fetch from Directus
const tr = p.translations?.[0]; let directusProjects: ProjectListItem[] = [];
const { translations: _translations, ...rest } = p; 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 { return {
...rest, id: p.id,
slug: p.slug,
title: tr?.title ?? p.title, title: tr?.title ?? p.title,
description: tr?.description ?? p.description, 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,52 @@
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,
// eslint-disable-next-line @next/next/no-img-element
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 { render, screen } from '@testing-library/react';
import Header from '@/app/components/Header'; 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', () => { describe('Header', () => {
it('renders the header', () => { it('renders the header with the dk logo', () => {
render(<Header />); render(<Header />);
expect(screen.getByText('dk')).toBeInTheDocument(); expect(screen.getByText('dk')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
const aboutButtons = screen.getAllByText('About'); // Check for navigation links (appear in both desktop and mobile menus)
expect(aboutButtons.length).toBeGreaterThan(0); expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
expect(screen.getAllByText('About').length).toBeGreaterThan(0);
const projectsButtons = screen.getAllByText('Projects'); expect(screen.getAllByText('Projects').length).toBeGreaterThan(0);
expect(projectsButtons.length).toBeGreaterThan(0); expect(screen.getAllByText('Contact').length).toBeGreaterThan(0);
const contactButtons = screen.getAllByText('Contact');
expect(contactButtons.length).toBeGreaterThan(0);
});
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,60 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import Hero from '@/app/components/Hero'; import Hero from '@/app/components/Hero';
import '@testing-library/jest-dom';
// Mock next-intl/server
jest.mock('next-intl/server', () => ({
getTranslations: () => Promise.resolve((key: string) => {
const messages: Record<string, string> = {
badge: 'Student & Self-Hoster',
line1: 'Building',
line2: 'Stuff.',
description: 'Dennis is a student and passionate self-hoster.',
ctaWork: 'View My Work',
ctaContact: 'Get in touch',
};
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) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
data-fill={fill?.toString()}
data-priority={priority?.toString()}
{...props}
/>
),
}));
describe('Hero', () => { describe('Hero', () => {
it('renders the hero section', () => { it('renders the hero section correctly', async () => {
render(<Hero />); const HeroResolved = await Hero({ locale: 'en' });
expect(screen.getByText('Dennis Konkol')).toBeInTheDocument(); render(HeroResolved);
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(); expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
// Check for CTA
expect(screen.getByText('View My Work')).toBeInTheDocument();
}); });
}); });

View File

@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { ThemeToggle } from "@/app/components/ThemeToggle";
// Mock custom ThemeProvider
jest.mock("@/app/components/ThemeProvider", () => ({
useTheme: () => ({
theme: "light",
setTheme: jest.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
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 { render, screen } from '@testing-library/react';
import NotFound from '@/app/not-found'; 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', () => { describe('NotFound', () => {
it('renders the 404 page', () => { it('renders the 404 page with the new design text', () => {
render(<NotFound />); 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 Contact from "../components/Contact";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Script from "next/script"; import Script from "next/script";
import ActivityFeedClient from "./ActivityFeedClient"; import { useEffect } from "react";
export default function HomePage() { export default function HomePage() {
useEffect(() => {
// Force scroll to top on mount to prevent starting at lower sections
window.scrollTo(0, 0);
}, []);
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Script <Script
@@ -32,15 +37,14 @@ export default function HomePage() {
}), }),
}} }}
/> />
<ActivityFeedClient />
<Header /> <Header />
{/* Spacer to prevent navbar overlap */} {/* 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"> <main className="relative">
<Hero /> <Hero locale="en" />
{/* Wavy Separator 1 - Hero to About */} {/* 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 <svg
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120" viewBox="0 0 1440 120"
@@ -63,7 +67,7 @@ export default function HomePage() {
<About /> <About />
{/* Wavy Separator 2 - About to Projects */} {/* 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 <svg
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120" viewBox="0 0 1440 120"
@@ -86,7 +90,7 @@ export default function HomePage() {
<Projects /> <Projects />
{/* Wavy Separator 3 - Projects to Contact */} {/* 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 <svg
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
viewBox="0 0 1440 120" viewBox="0 0 1440 120"

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

@@ -0,0 +1,141 @@
import Header from "../components/Header.server";
import Hero from "../components/Hero";
import ScrollFadeIn from "../components/ScrollFadeIn";
import Script from "next/script";
import {
getAboutTranslations,
getProjectsTranslations,
getContactTranslations,
getFooterTranslations,
} from "@/lib/translations-loader";
import {
AboutClient,
ProjectsClient,
ContactClient,
FooterClient,
} from "../components/ClientWrappers";
interface HomePageServerProps {
locale: string;
}
export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations (hero translations handled by Hero server component)
const [aboutT, projectsT, contactT, footerT] = await Promise.all([
getAboutTranslations(locale),
getProjectsTranslations(locale),
getContactTranslations(locale),
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">
<Hero locale={locale} />
{/* 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>
<ScrollFadeIn>
<AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn>
{/* 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>
<ScrollFadeIn>
<ProjectsClient locale={locale} translations={projectsT} />
</ScrollFadeIn>
{/* 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>
<ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn>
</main>
<ScrollFadeIn>
<FooterClient locale={locale} translations={footerT} />
</ScrollFadeIn>
</div>
);
}

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { motion } from "framer-motion"; import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from "lucide-react"; import { useEffect, useState } from "react";
import Link from "next/link";
import { useEffect } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from "next/navigation";
export type ProjectDetailData = { export type ProjectDetailData = {
id: number; id: number;
@@ -15,10 +16,16 @@ export type ProjectDetailData = {
tags: string[]; tags: string[];
featured: boolean; featured: boolean;
category: string; category: string;
date: string; date?: string;
created_at?: string;
github?: string | null; github?: string | null;
github_url?: string | null;
live?: string | null; live?: string | null;
button_live_label?: string | null;
button_github_label?: string | null;
imageUrl?: string | null; imageUrl?: string | null;
image_url?: string | null;
technologies?: string[];
}; };
export default function ProjectDetailClient({ export default function ProjectDetailClient({
@@ -28,211 +35,136 @@ export default function ProjectDetailClient({
project: ProjectDetailData; project: ProjectDetailData;
locale: string; 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(() => { useEffect(() => {
try { // Prüfen, ob wir eine History haben (von Home gekommen)
navigator.sendBeacon?.( if (typeof window !== 'undefined' && window.history.length > 1) {
"/api/analytics/track", setCanGoBack(true);
new Blob(
[
JSON.stringify({
type: "pageview",
projectId: project.id.toString(),
page: `/${locale}/projects/${project.slug}`,
}),
],
{ type: "application/json" },
),
);
} catch {
// ignore
} }
}, [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 ( return (
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20"> <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-4xl mx-auto px-4"> <div className="max-w-7xl mx-auto">
{/* Navigation */}
<motion.div {/* Navigation - Intelligent Back */}
initial={{ opacity: 0, y: 20 }} <button
animate={{ opacity: 1, y: 0 }} onClick={handleBack}
transition={{ duration: 0.6 }} 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"
className="mb-8"
>
<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" /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-medium">Back to Projects</span> <span className="font-bold uppercase tracking-widest text-xs">
</Link> {tCommon("back")}
</motion.div> </span>
</button>
{/* Header & Meta */} {/* Title Section */}
<motion.div <div className="mb-20">
initial={{ opacity: 0, y: 30 }} <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
animate={{ opacity: 1, y: 0 }} {project.title}<span className="text-liquid-mint">.</span>
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> </h1>
<div className="flex gap-2 shrink-0 pt-2"> <p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
{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">
{project.description} {project.description}
</p> </p>
</div>
<div className="flex flex-wrap items-center gap-6 text-stone-500 text-sm border-y border-stone-200 py-6"> {/* Feature Image Box */}
<div className="flex items-center space-x-2"> <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">
<Calendar size={18} /> <div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
<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>
))}
</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 ? ( {project.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element <Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
<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"> <div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
<span className="text-9xl font-serif font-bold text-stone-500/20 select-none"> <span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
{project.title.charAt(0)}
</span>
</div> </div>
)} )}
</motion.div>
{/* 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> </div>
</motion.div> </div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="lg:col-span-8 space-y-8">
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<ReactMarkdown>{project.content}</ReactMarkdown>
</div>
</div>
</div>
<div className="lg:col-span-4 space-y-8">
{/* Quick Links Box - Only show if links exist */}
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
<div className="space-y-4">
{project.live && project.live !== "#" && (
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black hover:scale-105 transition-transform group">
<span>{project.button_live_label || tDetail("liveDemo")}</span>
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform" />
{/* 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> </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>
)} )}
{project.github && project.github.trim() && project.github !== "#" ? ( {project.github && project.github !== "#" && (
<a
href={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">
target="_blank"
rel="noopener noreferrer" <span>{project.button_github_label || tDetail("viewSource")}</span>
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"
> <GithubIcon size={20} className="group-hover:rotate-12 transition-transform" />
<span>View Source</span>
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
</a> </a>
) : null}
)}
</div> </div>
<div className="mt-8 pt-6 border-t border-stone-100"> </div>
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">Tech Stack</h4>
)}
<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"> <div className="flex flex-wrap gap-2">
{project.tags.map((tag) => ( {project.tags.map((tag) => (
<span <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">
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} {tag}
</span> </span>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</motion.div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -2,21 +2,21 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion"; 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 Link from "next/link";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "../components/ui/Skeleton";
export type ProjectListItem = { export type ProjectListItem = {
id: number; id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
slug: string; slug: string;
title: string; title: string;
description: string; description: string;
content: string;
tags: string[]; tags: string[];
featured: boolean;
category: string; category: string;
date: string; date?: string;
github?: string | null; createdAt?: string;
live?: string | null;
imageUrl?: string | null; imageUrl?: string | null;
}; };
@@ -27,266 +27,131 @@ export default function ProjectsPageClient({
projects: ProjectListItem[]; projects: ProjectListItem[];
locale: string; 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 [searchQuery, setSearchQuery] = useState("");
const [mounted, setMounted] = useState(false); const [loading, setLoading] = useState(true);
useEffect(() => { 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 categories = useMemo(() => {
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean); const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
return ["All", ...unique]; return ["all", ...unique];
}, [projects]); }, [projects]);
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
let result = projects; let result = projects;
if (selectedCategory !== "all") {
if (selectedCategory !== "All") {
result = result.filter((project) => project.category === selectedCategory); result = result.filter((project) => project.category === selectedCategory);
} }
if (searchQuery) { if (searchQuery) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
result = result.filter( result = result.filter(
(project) => (p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
project.title.toLowerCase().includes(query) ||
project.description.toLowerCase().includes(query) ||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
); );
} }
return result; return result;
}, [projects, selectedCategory, searchQuery]); }, [projects, selectedCategory, searchQuery]);
if (!mounted) return null;
return ( return (
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20"> <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 px-4"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<motion.div <div className="mb-24">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
className="mb-12"
>
<Link <Link
href={`/${locale}`} 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" /> <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> </Link>
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight"> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
My Projects Archive<span className="text-liquid-mint">.</span>
</h1> </h1>
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed"> <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">
Explore my portfolio of projects, from web applications to mobile apps. Each project showcases different {tList("intro")}
skills and technologies.
</p> </p>
</motion.div> </div>
{/* Filters & Search */} {/* Filters */}
<motion.div <div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
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 */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{categories.map((category) => ( {categories.map((cat) => (
<button <button
key={category} key={cat}
onClick={() => setSelectedCategory(category)} onClick={() => setSelectedCategory(cat)}
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${ className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
selectedCategory === category selectedCategory === cat
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md" ? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300" : "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
}`} }`}
> >
{category} {cat === 'all' ? tList('all') : cat}
</button> </button>
))} ))}
</div> </div>
<div className="relative w-full md:w-80">
{/* Search */} <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
<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 <input
type="text" type="text"
placeholder="Search projects..." placeholder={tList("searchPlaceholder")}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} 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> </div>
</motion.div> </div>
{/* Projects Grid */} {/* Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{filteredProjects.map((project, index) => ( {loading ? (
<motion.div Array.from({ length: 4 }).map((_, i) => (
key={project.id} <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">
initial={{ opacity: 0, y: 30 }} <Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
animate={{ opacity: 1, y: 0 }} <div className="space-y-3">
transition={{ duration: 0.6, delay: index * 0.1 }} <Skeleton className="h-8 w-1/2" />
whileHover={{ y: -8 }} <Skeleton className="h-4 w-3/4" />
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" </div>
> </div>
{/* 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"> filteredProjects.map((project) => (
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" /> <motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" /> <Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" /> <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 z-10"> <div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
<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"> <Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
{project.title.charAt(0)}
</span>
</div>
</div> </div>
)} )}
<div className="flex-1 flex flex-col">
{/* Texture/Grain Overlay */} <div className="flex justify-between items-start mb-4">
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" /> <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">
{/* Animated Shine Effect */} <ArrowUpRight size={20} />
<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>
</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">
{/* Overlay Links */} {project.tags.slice(0, 3).map(tag => (
<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"> <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>
{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> </div>
</div> </div>
</Link>
</motion.div> </motion.div>
))} )))}
</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>
)}
</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 dynamic = 'force-dynamic';
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,12 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getContentByKey } from "@/lib/content"; import { getContentByKey } from "@/lib/content";
import { getContentPage } from "@/lib/directus";
import { richTextToSafeHtml } from "@/lib/richtext";
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@@ -11,9 +18,38 @@ export async function GET(request: NextRequest) {
} }
try { try {
// 1) Try Directus first
const directusPage = await getContentPage(key, locale);
if (directusPage) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : "";
return NextResponse.json(
{
content: {
title: directusPage.title,
slug: directusPage.slug,
locale: directusPage.locale || locale,
content: directusPage.content,
html,
},
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 }); const translation = await getContentByKey({ key, locale });
if (!translation) return NextResponse.json({ content: null }); if (!translation) {
return NextResponse.json({ content: 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) { } catch (error) {
// If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings. // If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings.
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {

View File

@@ -4,15 +4,12 @@ import SMTPTransport from "nodemailer/lib/smtp-transport";
import Mail from "nodemailer/lib/mailer"; import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders, getClientIp, requireSessionAuth } from "@/lib/auth"; import { checkRateLimit, getRateLimitHeaders, getClientIp, requireSessionAuth } from "@/lib/auth";
const BRAND = { const B = {
siteUrl: "https://dk0.dev", siteUrl: "https://dk0.dev",
email: "contact@dk0.dev", email: "contact@dk0.dev",
bg: "#FDFCF8",
sand: "#F3F1E7",
border: "#E7E5E4",
text: "#292524",
muted: "#78716C",
mint: "#A7F3D0", mint: "#A7F3D0",
sky: "#BAE6FD",
purple: "#E9D5FF",
red: "#EF4444", red: "#EF4444",
}; };
@@ -26,58 +23,86 @@ function escapeHtml(input: string): string {
} }
function nl2br(input: string): string { function nl2br(input: string): string {
return input.replace(/\r\n|\r|\n/g, "<br>"); return escapeHtml(input).replace(/\r\n|\r|\n/g, "<br>");
} }
function baseEmail(opts: { title: string; subtitle: string; bodyHtml: string }) { function baseEmail(opts: { title: string; preheader: string; bodyHtml: string }): string {
const sentAt = new Date().toLocaleString("de-DE", { const sentAt = new Date().toLocaleString("de-DE", {
year: "numeric", year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}); });
return ` return `<!DOCTYPE html>
<!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>${escapeHtml(opts.title)}</title> <title>${escapeHtml(opts.title)}</title>
</head> </head>
<body style="margin:0;padding:0;background-color:${BRAND.bg};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:${BRAND.text};"> <body style="margin:0;padding:0;background-color:#0c0c0c;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid ${BRAND.border};border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);"> <div style="max-width:600px;margin:0 auto;padding:24px 16px 40px;">
<div style="background:${BRAND.text};padding:22px 26px;"> <div style="background:#141414;border-radius:24px;overflow:hidden;border:1px solid #222;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:800;font-size:16px;color:${BRAND.bg};">Dennis Konkol</div> <!-- Header -->
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:800;font-size:14px;color:${BRAND.bg};"> <div style="background:#111;border-bottom:1px solid #1e1e1e;">
dk<span style="color:${BRAND.red};">0</span>.dev <div style="height:3px;background:linear-gradient(90deg,${B.mint} 0%,${B.sky} 50%,${B.purple} 100%);"></div>
<div style="padding:28px 28px 24px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
<div>
<div style="font-size:10px;letter-spacing:0.15em;text-transform:uppercase;color:#555;font-weight:700;margin-bottom:8px;">
${escapeHtml(opts.preheader)} &middot; ${sentAt}
</div>
<div style="font-size:26px;font-weight:900;color:#f3f4f6;letter-spacing:-0.03em;line-height:1.15;">
${escapeHtml(opts.title)}
</div>
</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;font-weight:800;color:#374151;flex-shrink:0;padding-top:4px;">
dk<span style="color:${B.red};">0</span>.dev
</div> </div>
</div> </div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:900;letter-spacing:-0.02em;color:${BRAND.bg};">${escapeHtml(opts.title)}</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">${escapeHtml(opts.subtitle)}${sentAt}</div>
</div> </div>
<div style="height:3px;background:${BRAND.mint};margin-top:18px;border-radius:999px;"></div>
</div> </div>
<div style="padding:26px;"> <!-- Body -->
<div style="padding:28px;">
${opts.bodyHtml} ${opts.bodyHtml}
</div> </div>
<div style="padding:18px 26px;background:${BRAND.bg};border-top:1px solid ${BRAND.border};"> <!-- Footer -->
<div style="font-size:12px;color:${BRAND.muted};line-height:1.5;"> <div style="padding:16px 28px;background:#0c0c0c;border-top:1px solid #1a1a1a;">
Automatisch generiert von <a href="${BRAND.siteUrl}" style="color:${BRAND.text};text-decoration:underline;">dk0.dev</a> • <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
<a href="mailto:${BRAND.email}" style="color:${BRAND.text};text-decoration:underline;">${BRAND.email}</a> <div style="font-size:11px;color:#374151;">
<a href="${B.siteUrl}" style="color:#4b5563;text-decoration:none;">${B.siteUrl}</a>
</div>
<div style="font-size:11px;color:#374151;">
<a href="mailto:${B.email}" style="color:#4b5563;text-decoration:none;">${B.email}</a>
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
</body> </body>
</html> </html>`;
`.trim(); }
function messageCard(label: string, html: string, accentColor: string = B.mint): string {
return `
<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-left:3px solid ${accentColor};border-radius:0 12px 12px 0;overflow:hidden;">
<div style="padding:10px 16px;background:#161616;border-bottom:1px solid #1e1e1e;">
<span style="font-size:10px;letter-spacing:0.14em;text-transform:uppercase;font-weight:700;color:#4b5563;">${label}</span>
</div>
<div style="padding:16px 18px;font-size:15px;line-height:1.75;color:#d1d5db;">${html}</div>
</div>`;
}
function ctaButton(text: string, href: string): string {
return `
<div style="margin-top:24px;text-align:center;">
<a href="${href}" style="display:inline-block;background:linear-gradient(135deg,${B.mint},${B.sky});color:#111;text-decoration:none;padding:14px 32px;border-radius:12px;font-weight:800;font-size:15px;letter-spacing:-0.01em;">
${text}
</a>
</div>`;
} }
const emailTemplates = { const emailTemplates = {
@@ -85,31 +110,16 @@ const emailTemplates = {
subject: "Vielen Dank für deine Nachricht! 👋", subject: "Vielen Dank für deine Nachricht! 👋",
template: (name: string, originalMessage: string) => { template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({ return baseEmail({
title: `Danke, ${safeName}!`, title: `Danke, ${safeName}!`,
subtitle: "Nachricht erhalten", preheader: "Nachricht erhalten",
bodyHtml: ` bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};"> <p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
Hey ${safeName},<br><br> Hey ${safeName},<br><br>
danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. 🙌
</div> </p>
${messageCard("Deine Nachricht", nl2br(originalMessage))}
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;"> ${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
<div style="margin-top:20px;text-align:center;">
<a href="${BRAND.siteUrl}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Portfolio ansehen
</a>
</div>
`.trim(),
}); });
}, },
}, },
@@ -117,31 +127,16 @@ const emailTemplates = {
subject: "Projekt-Anfrage erhalten! 🚀", subject: "Projekt-Anfrage erhalten! 🚀",
template: (name: string, originalMessage: string) => { template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({ return baseEmail({
title: `Projekt-Anfrage: danke, ${safeName}!`, title: `Projekt-Anfrage: danke, ${safeName}!`,
subtitle: "Ich melde mich zeitnah", preheader: "Ich melde mich zeitnah",
bodyHtml: ` bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};"> <p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
Hey ${safeName},<br><br> Hey ${safeName},<br><br>
mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu. mega — danke für die Projekt-Anfrage! Ich schaue mir alles an und melde mich bald mit Ideen und Rückfragen. 🚀
</div> </p>
${messageCard("Deine Projekt-Anfrage", nl2br(originalMessage), B.sky)}
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;"> ${ctaButton("Mein Portfolio ansehen →", B.siteUrl)}`,
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Projekt-Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
<div style="margin-top:20px;text-align:center;">
<a href="mailto:${BRAND.email}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Kontakt aufnehmen
</a>
</div>
`.trim(),
}); });
}, },
}, },
@@ -149,25 +144,15 @@ const emailTemplates = {
subject: "Danke für deine Nachricht! ⚡", subject: "Danke für deine Nachricht! ⚡",
template: (name: string, originalMessage: string) => { template: (name: string, originalMessage: string) => {
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeMsg = nl2br(escapeHtml(originalMessage));
return baseEmail({ return baseEmail({
title: `Danke, ${safeName}!`, title: `Danke, ${safeName}!`,
subtitle: "Kurze Bestätigung", preheader: "Kurze Bestätigung",
bodyHtml: ` bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};"> <p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
Hey ${safeName},<br><br> Hey ${safeName},<br><br>
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
</div> </p>
${messageCard("Deine Nachricht", nl2br(originalMessage))}`,
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeMsg}
</div>
</div>
`.trim(),
}); });
}, },
}, },
@@ -175,35 +160,19 @@ const emailTemplates = {
subject: "Antwort auf deine Nachricht 📧", subject: "Antwort auf deine Nachricht 📧",
template: (name: string, originalMessage: string, responseMessage: string) => { template: (name: string, originalMessage: string, responseMessage: string) => {
const safeName = escapeHtml(name); const safeName = escapeHtml(name);
const safeOriginal = nl2br(escapeHtml(originalMessage));
const safeResponse = nl2br(escapeHtml(responseMessage));
return baseEmail({ return baseEmail({
title: `Antwort für ${safeName}`, title: `Hey ${safeName}!`,
subtitle: "Neue Nachricht", preheader: "Antwort von Dennis",
bodyHtml: ` bodyHtml: `
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};"> <p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
Hey ${safeName},<br><br> Hey ${safeName},<br><br>
hier ist meine Antwort: ich habe mir deine Nachricht angeschaut — hier ist meine Antwort:
</p>
${messageCard("Antwort von Dennis", nl2br(responseMessage), B.mint)}
<div style="margin-top:16px;">
${messageCard("Deine ursprüngliche Nachricht", nl2br(originalMessage), "#2a2a2a")}
</div> </div>
${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
<div style="margin-top:14px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Antwort</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
${safeResponse}
</div>
</div>
<div style="margin-top:16px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine ursprüngliche Nachricht</div>
</div>
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.border};">
${safeOriginal}
</div>
</div>
`.trim(),
}); });
}, },
}, },
@@ -234,33 +203,20 @@ export async function POST(request: NextRequest) {
const { to, name, template, originalMessage, response } = body; const { to, name, template, originalMessage, response } = body;
// Validate input
if (!to || !name || !template || !originalMessage) { if (!to || !name || !template || !originalMessage) {
return NextResponse.json( return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
{ error: "Alle Felder sind erforderlich" },
{ status: 400 },
);
} }
if (template === "reply" && (!response || !response.trim())) { if (template === "reply" && (!response || !response.trim())) {
return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 }); return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 });
} }
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(to)) { if (!emailRegex.test(to)) {
console.error('❌ Validation failed: Invalid email format'); return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
return NextResponse.json(
{ error: "Ungültige E-Mail-Adresse" },
{ status: 400 },
);
} }
// Check if template exists
if (!emailTemplates[template]) { if (!emailTemplates[template]) {
return NextResponse.json( return NextResponse.json({ error: "Ungültiges Template" }, { status: 400 });
{ error: "Ungültiges Template" },
{ status: 400 },
);
} }
const user = process.env.MY_EMAIL ?? ""; const user = process.env.MY_EMAIL ?? "";
@@ -268,10 +224,7 @@ export async function POST(request: NextRequest) {
if (!user || !pass) { if (!user || !pass) {
console.error("❌ Missing email/password environment variables"); console.error("❌ Missing email/password environment variables");
return NextResponse.json( return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
{ error: "E-Mail-Server nicht konfiguriert" },
{ status: 500 },
);
} }
const transportOptions: SMTPTransport.Options = { const transportOptions: SMTPTransport.Options = {
@@ -279,86 +232,50 @@ export async function POST(request: NextRequest) {
port: 587, port: 587,
secure: false, secure: false,
requireTLS: true, requireTLS: true,
auth: { auth: { type: "login", user, pass },
type: "login",
user,
pass,
},
connectionTimeout: 30000, connectionTimeout: 30000,
greetingTimeout: 30000, greetingTimeout: 30000,
socketTimeout: 60000, socketTimeout: 60000,
tls: { tls: { rejectUnauthorized: false, ciphers: 'SSLv3' },
rejectUnauthorized: false,
ciphers: 'SSLv3'
}
}; };
const transport = nodemailer.createTransport(transportOptions); const transport = nodemailer.createTransport(transportOptions);
// Verify transport configuration
try { try {
await transport.verify(); await transport.verify();
} catch (_verifyError) { } catch {
return NextResponse.json( return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
{ status: 500 },
);
} }
const selectedTemplate = emailTemplates[template]; const selectedTemplate = emailTemplates[template];
let html: string; const html = template === "reply"
if (template === "reply") { ? emailTemplates.reply.template(name, originalMessage, response || "")
html = emailTemplates.reply.template(name, originalMessage, response || ""); : emailTemplates[template as Exclude<typeof template, "reply">].template(name, originalMessage);
} else {
// Narrow the template type so TS knows this is not the 3-arg reply template
const nonReplyTemplate = template as Exclude<typeof template, "reply">;
html = emailTemplates[nonReplyTemplate].template(name, originalMessage);
}
const mailOptions: Mail.Options = { const mailOptions: Mail.Options = {
from: `"Dennis Konkol" <${user}>`, from: `"Dennis Konkol" <${user}>`,
to: to, to,
replyTo: "contact@dk0.dev", replyTo: B.email,
subject: selectedTemplate.subject, subject: selectedTemplate.subject,
html, html,
text: ` text: template === "reply"
Hallo ${name}! ? `Hey ${name}!\n\nAntwort:\n${response}\n\nDeine ursprüngliche Nachricht:\n${originalMessage}\n\n-- Dennis Konkol\n${B.siteUrl}`
: `Hey ${name}!\n\nDanke für deine Nachricht:\n${originalMessage}\n\nIch melde mich bald!\n\n-- Dennis Konkol\n${B.siteUrl}`,
Vielen Dank für deine Nachricht:
${originalMessage}
${template === "reply" ? `\nAntwort:\n${response || ""}\n` : "\nIch werde mich so schnell wie möglich bei dir melden.\n"}
Beste Grüße,
Dennis Konkol
Software Engineer & Student
https://dki.one
contact@dk0.dev
`,
}; };
const sendMailPromise = () => const result = await new Promise<string>((resolve, reject) => {
new Promise<string>((resolve, reject) => { transport.sendMail(mailOptions, (err, info) => {
transport.sendMail(mailOptions, function (err, info) { if (!err) resolve(info.response);
if (!err) { else reject(err.message);
resolve(info.response);
} else {
reject(err.message);
}
}); });
}); });
const result = await sendMailPromise(); return NextResponse.json({ message: "E-Mail erfolgreich gesendet", template, messageId: result });
return NextResponse.json({
message: "Template-E-Mail erfolgreich gesendet",
template: template,
messageId: result
});
} catch (err) { } catch (err) {
return NextResponse.json({ return NextResponse.json({
error: "Fehler beim Senden der Template-E-Mail", error: "Fehler beim Senden der E-Mail",
details: err instanceof Error ? err.message : 'Unbekannter Fehler' details: err instanceof Error ? err.message : 'Unbekannter Fehler',
}, { status: 500 }); }, { status: 500 });
} }
} }

View File

@@ -5,12 +5,8 @@ import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth'; import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
// Sanitize input to prevent XSS
function sanitizeInput(input: string, maxLength: number = 10000): string { function sanitizeInput(input: string, maxLength: number = 10000): string {
return input return input.slice(0, maxLength).replace(/[<>]/g, '').trim();
.slice(0, maxLength)
.replace(/[<>]/g, '') // Remove potential HTML tags
.trim();
} }
function escapeHtml(input: string): string { function escapeHtml(input: string): string {
@@ -22,19 +18,126 @@ function escapeHtml(input: string): string {
.replace(/'/g, "&#39;"); .replace(/'/g, "&#39;");
} }
function buildNotificationEmail(opts: {
name: string;
email: string;
subject: string;
messageHtml: string;
initial: string;
replyHref: string;
sentAt: string;
}): string {
const { name, email, subject, messageHtml, initial, replyHref, sentAt } = opts;
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Neue Kontaktanfrage</title>
</head>
<body style="margin:0;padding:0;background-color:#0c0c0c;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
<div style="max-width:600px;margin:0 auto;padding:24px 16px 40px;">
<!-- Card -->
<div style="background:#141414;border-radius:24px;overflow:hidden;border:1px solid #222;">
<!-- Header -->
<div style="background:#111;padding:0 0 0 0;border-bottom:1px solid #1e1e1e;">
<!-- Gradient bar -->
<div style="height:3px;background:linear-gradient(90deg,#a7f3d0 0%,#bae6fd 50%,#e9d5ff 100%);"></div>
<div style="padding:28px 28px 24px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;">
<div>
<div style="font-size:10px;letter-spacing:0.15em;text-transform:uppercase;color:#555;font-weight:700;margin-bottom:8px;">
dk0.dev · Portfolio Kontakt
</div>
<div style="font-size:26px;font-weight:900;color:#f3f4f6;letter-spacing:-0.03em;line-height:1.15;">
Neue Kontaktanfrage
</div>
<div style="margin-top:6px;font-size:13px;color:#4b5563;">
${escapeHtml(sentAt)}
</div>
</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;font-weight:800;color:#374151;flex-shrink:0;padding-top:4px;">
dk<span style="color:#ef4444;">0</span>.dev
</div>
</div>
</div>
</div>
<!-- Sender -->
<div style="padding:24px 28px;border-bottom:1px solid #1e1e1e;">
<div style="display:flex;align-items:center;gap:16px;">
<!-- Avatar -->
<div style="width:52px;height:52px;border-radius:16px;background:linear-gradient(135deg,#a7f3d0,#bae6fd);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:900;color:#111;flex-shrink:0;">
${escapeHtml(initial)}
</div>
<div style="min-width:0;">
<div style="font-size:18px;font-weight:800;color:#f9fafb;letter-spacing:-0.02em;">${escapeHtml(name)}</div>
<div style="font-size:13px;color:#6b7280;margin-top:3px;">${escapeHtml(email)}</div>
</div>
</div>
<!-- Subject pill -->
<div style="margin-top:16px;">
<span style="display:inline-flex;align-items:center;gap:7px;background:#1c1c1c;border:1px solid #2a2a2a;border-radius:100px;padding:6px 14px;">
<span style="width:6px;height:6px;border-radius:50%;background:#a7f3d0;display:inline-block;flex-shrink:0;"></span>
<span style="font-size:13px;font-weight:600;color:#d1d5db;">${escapeHtml(subject)}</span>
</span>
</div>
</div>
<!-- Message -->
<div style="padding:24px 28px;border-bottom:1px solid #1e1e1e;">
<div style="font-size:10px;letter-spacing:0.14em;text-transform:uppercase;font-weight:700;color:#4b5563;margin-bottom:12px;">
Nachricht
</div>
<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-left:3px solid #a7f3d0;border-radius:0 12px 12px 0;padding:18px 20px;font-size:15px;line-height:1.75;color:#d1d5db;">
${messageHtml}
</div>
</div>
<!-- CTA -->
<div style="padding:24px 28px;border-bottom:1px solid #1e1e1e;">
<a href="${escapeHtml(replyHref)}"
style="display:block;text-align:center;background:linear-gradient(135deg,#a7f3d0,#bae6fd);color:#111;text-decoration:none;padding:14px 24px;border-radius:12px;font-weight:800;font-size:15px;letter-spacing:-0.01em;">
Direkt antworten &rarr;
</a>
<div style="margin-top:10px;text-align:center;font-size:12px;color:#374151;">
Oder einfach auf diese E-Mail antworten — Reply-To ist bereits gesetzt.
</div>
</div>
<!-- Footer -->
<div style="padding:16px 28px;background:#0c0c0c;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
<div style="font-size:11px;color:#374151;">
Automatisch generiert &middot; <a href="https://dk0.dev" style="color:#4b5563;text-decoration:none;">dk0.dev</a>
</div>
<div style="font-size:11px;color:#374151;">
contact@dk0.dev
</div>
</div>
</div>
</div>
</div>
</body>
</html>`;
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
// Rate limiting (defensive: headers may be undefined in tests)
const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown'; const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 emails per minute per IP if (!checkRateLimit(ip, 5, 60000)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' }, { error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' },
{ {
status: 429, status: 429,
headers: { headers: { 'Content-Type': 'application/json', ...getRateLimitHeaders(ip, 5, 60000) },
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
} }
); );
} }
@@ -46,48 +149,26 @@ export async function POST(request: NextRequest) {
message: string; message: string;
}; };
// Sanitize and validate input
const email = sanitizeInput(body.email || '', 255); const email = sanitizeInput(body.email || '', 255);
const name = sanitizeInput(body.name || '', 100); const name = sanitizeInput(body.name || '', 100);
const subject = sanitizeInput(body.subject || '', 200); const subject = sanitizeInput(body.subject || '', 200);
const message = sanitizeInput(body.message || '', 5000); const message = sanitizeInput(body.message || '', 5000);
// Email request received
// Validate input
if (!email || !name || !subject || !message) { if (!email || !name || !subject || !message) {
console.error('❌ Validation failed: Missing required fields'); return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
return NextResponse.json(
{ error: "Alle Felder sind erforderlich" },
{ status: 400 },
);
} }
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(email)) {
console.error('❌ Validation failed: Invalid email format'); return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
return NextResponse.json(
{ error: "Ungültige E-Mail-Adresse" },
{ status: 400 },
);
} }
// Validate message length
if (message.length < 10) { if (message.length < 10) {
console.error('❌ Validation failed: Message too short'); return NextResponse.json({ error: "Nachricht muss mindestens 10 Zeichen lang sein" }, { status: 400 });
return NextResponse.json(
{ error: "Nachricht muss mindestens 10 Zeichen lang sein" },
{ status: 400 },
);
} }
// Validate field lengths
if (name.length > 100 || subject.length > 200 || message.length > 5000) { if (name.length > 100 || subject.length > 200 || message.length > 5000) {
return NextResponse.json( return NextResponse.json({ error: "Eingabe zu lang" }, { status: 400 });
{ error: "Eingabe zu lang" },
{ status: 400 },
);
} }
const user = process.env.MY_EMAIL ?? ""; const user = process.env.MY_EMAIL ?? "";
@@ -95,265 +176,98 @@ export async function POST(request: NextRequest) {
if (!user || !pass) { if (!user || !pass) {
console.error("❌ Missing email/password environment variables"); console.error("❌ Missing email/password environment variables");
return NextResponse.json( return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
{ error: "E-Mail-Server nicht konfiguriert" },
{ status: 500 },
);
} }
const transportOptions: SMTPTransport.Options = { const transportOptions: SMTPTransport.Options = {
host: "mail.dk0.dev", host: "mail.dk0.dev",
port: 587, port: 587,
secure: false, // Port 587 uses STARTTLS, not SSL/TLS secure: false,
requireTLS: true, requireTLS: true,
auth: { auth: { type: "login", user, pass },
type: "login", connectionTimeout: 30000,
user, greetingTimeout: 30000,
pass, socketTimeout: 60000,
},
// Increased timeout settings for better reliability
connectionTimeout: 30000, // 30 seconds
greetingTimeout: 30000, // 30 seconds
socketTimeout: 60000, // 60 seconds
// TLS hardening (allow insecure/self-signed only when explicitly enabled)
tls: tls:
process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true"
process.env.SMTP_ALLOW_SELF_SIGNED === "true"
? { rejectUnauthorized: false } ? { rejectUnauthorized: false }
: { rejectUnauthorized: true, minVersion: "TLSv1.2" }, : { rejectUnauthorized: true, minVersion: "TLSv1.2" },
}; };
// Creating transport with configured options
const transport = nodemailer.createTransport(transportOptions); const transport = nodemailer.createTransport(transportOptions);
// Verify transport configuration with retry logic
let verificationAttempts = 0; let verificationAttempts = 0;
const maxVerificationAttempts = 3; while (verificationAttempts < 3) {
let verificationSuccess = false;
while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
try { try {
verificationAttempts++; verificationAttempts++;
await transport.verify(); await transport.verify();
verificationSuccess = true; break;
} catch (verifyError) { } catch (verifyError) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError); console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
} }
if (verificationAttempts >= 3) {
if (verificationAttempts >= maxVerificationAttempts) { return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
if (process.env.NODE_ENV === 'development') {
console.error('All SMTP verification attempts failed');
} }
return NextResponse.json(
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
{ status: 500 },
);
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
const brandUrl = "https://dk0.dev";
const sentAt = new Date().toLocaleString('de-DE', { const sentAt = new Date().toLocaleString('de-DE', {
year: 'numeric', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}); });
const safeName = escapeHtml(name);
const safeEmail = escapeHtml(email);
const safeSubject = escapeHtml(subject);
const safeMessageHtml = escapeHtml(message).replace(/\n/g, "<br>");
const initial = (name.trim()[0] || "?").toUpperCase(); const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`; const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
const messageHtml = escapeHtml(message).replace(/\n/g, "<br>");
const mailOptions: Mail.Options = { const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`, from: `"Portfolio Contact" <${user}>`,
to: "contact@dk0.dev", // Send to your contact email to: "contact@dk0.dev",
replyTo: email, replyTo: email,
subject: `Portfolio Kontakt: ${subject}`, subject: `📬 Neue Anfrage: ${subject}`,
html: ` html: buildNotificationEmail({ name, email, subject, messageHtml, initial, replyHref, sentAt }),
<!DOCTYPE html> text: `Neue Kontaktanfrage\n\nVon: ${name} (${email})\nBetreff: ${subject}\n\n${message}\n\n---\nEingegangen: ${sentAt}`,
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neue Kontaktanfrage - Portfolio</title>
</head>
<body style="margin:0;padding:0;background-color:#fdfcf8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#292524;">
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
<div style="background:#ffffff;border:1px solid #e7e5e4;border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
<!-- Top bar -->
<div style="background:#292524;padding:22px 26px;">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
<div style="font-weight:700;font-size:16px;letter-spacing:-0.01em;color:#fdfcf8;">
Dennis Konkol
</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:700;font-size:14px;color:#fdfcf8;">
dk<span style="color:#ef4444;">0</span>.dev
</div>
</div>
<div style="margin-top:10px;">
<div style="font-size:22px;font-weight:800;letter-spacing:-0.02em;color:#fdfcf8;">
Neue Kontaktanfrage
</div>
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">
Eingegangen am ${sentAt}
</div>
</div>
<div style="height:3px;background:#a7f3d0;margin-top:18px;border-radius:999px;"></div>
</div>
<!-- Content -->
<div style="padding:26px;">
<!-- Sender -->
<div style="display:flex;align-items:flex-start;gap:14px;">
<div style="width:44px;height:44px;border-radius:14px;background:#f3f1e7;border:1px solid #e7e5e4;display:flex;align-items:center;justify-content:center;font-weight:800;color:#292524;">
${escapeHtml(initial)}
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#292524;line-height:1.2;">
${safeName}
</div>
<div style="margin-top:6px;font-size:13px;color:#78716c;line-height:1.4;">
<span style="font-weight:700;color:#44403c;">E-Mail:</span> ${safeEmail}<br>
<span style="font-weight:700;color:#44403c;">Betreff:</span> ${safeSubject}
</div>
</div>
</div>
<!-- Message -->
<div style="margin-top:18px;background:#fdfcf8;border:1px solid #e7e5e4;border-radius:16px;overflow:hidden;">
<div style="padding:14px 16px;background:#f3f1e7;border-bottom:1px solid #e7e5e4;">
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">
Nachricht
</div>
</div>
<div style="padding:16px;line-height:1.65;color:#292524;font-size:15px;border-left:4px solid #a7f3d0;">
${safeMessageHtml}
</div>
</div>
<!-- CTA -->
<div style="margin-top:22px;text-align:center;">
<a href="${escapeHtml(replyHref)}"
style="display:inline-block;background:#292524;color:#fdfcf8;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
Antworten
</a>
<div style="margin-top:10px;font-size:12px;color:#78716c;">
Oder antworte direkt auf diese E-Mail.
</div>
</div>
</div>
<!-- Footer -->
<div style="padding:18px 26px;background:#fdfcf8;border-top:1px solid #e7e5e4;">
<div style="font-size:12px;color:#78716c;line-height:1.5;">
Automatisch generiert von <a href="${brandUrl}" style="color:#292524;text-decoration:underline;">dk0.dev</a>
</div>
</div>
</div>
</div>
</body>
</html>
`,
text: `
Neue Kontaktanfrage von deinem Portfolio
Von: ${name} (${email})
Betreff: ${subject}
Nachricht:
${message}
---
Diese E-Mail wurde automatisch von dk0.dev generiert.
`,
}; };
// Sending email
// Email sending with retry logic
let sendAttempts = 0; let sendAttempts = 0;
const maxSendAttempts = 3;
let sendSuccess = false;
let result = ''; let result = '';
while (sendAttempts < maxSendAttempts && !sendSuccess) { while (sendAttempts < 3) {
try { try {
sendAttempts++; sendAttempts++;
// Email send attempt result = await new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, (err, info) => {
const sendMailPromise = () => if (!err) resolve(info.response);
new Promise<string>((resolve, reject) => { else {
transport.sendMail(mailOptions, function (err, info) { if (process.env.NODE_ENV === 'development') console.error("Error sending email:", err);
if (!err) {
// Email sent successfully
resolve(info.response);
} else {
if (process.env.NODE_ENV === 'development') {
console.error("Error sending email:", err);
}
reject(err.message); reject(err.message);
} }
}); });
}); });
break;
result = await sendMailPromise();
sendSuccess = true;
// Email process completed successfully
} catch (sendError) { } catch (sendError) {
if (process.env.NODE_ENV === 'development') { if (sendAttempts >= 3) {
console.error(`Email send attempt ${sendAttempts} failed:`, sendError); throw new Error(`Failed to send email after 3 attempts: ${sendError}`);
} }
if (sendAttempts >= maxSendAttempts) {
if (process.env.NODE_ENV === 'development') {
console.error('All email send attempts failed');
}
throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 3000)); await new Promise(resolve => setTimeout(resolve, 3000));
} }
} }
// Save contact to database // Save to DB
try { try {
await prisma.contact.create({ await prisma.contact.create({ data: { name, email, subject, message, responded: false } });
data: {
name,
email,
subject,
message,
responded: false
}
});
// Contact saved to database
} catch (dbError) { } catch (dbError) {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError);
console.error('Error saving contact to database:', dbError);
}
// Don't fail the email send if DB save fails
} }
return NextResponse.json({ return NextResponse.json({ message: "E-Mail erfolgreich gesendet", messageId: result });
message: "E-Mail erfolgreich gesendet",
messageId: result
});
} catch (err) { } catch (err) {
console.error("❌ Unexpected error in email API:", err); console.error("❌ Unexpected error in email API:", err);
return NextResponse.json({ return NextResponse.json({
error: "Fehler beim Senden der E-Mail", error: "Fehler beim Senden der E-Mail",
details: err instanceof Error ? err.message : 'Unbekannter Fehler' details: err instanceof Error ? err.message : 'Unbekannter Fehler',
}, { status: 500 }); }, { status: 500 });
} }
} }

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

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

View File

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

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

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

View File

@@ -44,10 +44,12 @@ export async function POST(request: NextRequest) {
// Ensure URL doesn't have trailing slash before adding /webhook/chat // Ensure URL doesn't have trailing slash before adding /webhook/chat
const baseUrl = n8nWebhookUrl.replace(/\/$/, ''); const baseUrl = n8nWebhookUrl.replace(/\/$/, '');
const webhookUrl = `${baseUrl}/webhook/chat`; const webhookUrl = `${baseUrl}/webhook/chat`;
if (process.env.NODE_ENV === 'development') {
console.log(`Sending to n8n: ${webhookUrl}`, { console.log(`Sending to n8n: ${webhookUrl}`, {
hasSecretToken: !!process.env.N8N_SECRET_TOKEN, hasSecretToken: !!process.env.N8N_SECRET_TOKEN,
hasApiKey: !!process.env.N8N_API_KEY, hasApiKey: !!process.env.N8N_API_KEY,
}); });
}
// Add timeout to prevent hanging requests // Add timeout to prevent hanging requests
const controller = new AbortController(); const controller = new AbortController();
@@ -76,20 +78,24 @@ export async function POST(request: NextRequest) {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error'); const errorText = await response.text().catch(() => 'Unknown error');
if (process.env.NODE_ENV === 'development') {
console.error(`n8n webhook failed with status: ${response.status}`, { console.error(`n8n webhook failed with status: ${response.status}`, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
error: errorText, error: errorText,
webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs webhookUrl: webhookUrl.replace(/\/\/[^:]+:[^@]+@/, '//***:***@'), // Hide credentials in logs
}); });
}
throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`); throw new Error(`n8n webhook failed: ${response.status} - ${errorText.substring(0, 200)}`);
} }
const data = await response.json(); const data = await response.json();
if (process.env.NODE_ENV === 'development') {
console.log("n8n response data (full):", JSON.stringify(data, null, 2)); console.log("n8n response data (full):", JSON.stringify(data, null, 2));
console.log("n8n response data type:", typeof data); console.log("n8n response data type:", typeof data);
console.log("n8n response is array:", Array.isArray(data)); console.log("n8n response is array:", Array.isArray(data));
}
// Try multiple ways to extract the reply // Try multiple ways to extract the reply
let reply: string | undefined = undefined; let reply: string | undefined = undefined;

View File

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

View File

@@ -0,0 +1,125 @@
/**
* POST /api/n8n/hardcover/sync-books
*
* Called by an n8n workflow whenever books are finished in Hardcover.
* Creates new entries in the Directus book_reviews collection.
* Deduplicates by hardcover_id — safe to call repeatedly.
*
* n8n Workflow setup:
* 1. Schedule Trigger (every hour)
* 2. HTTP Request → Hardcover GraphQL (query: me { books_read(limit: 20) { ... } })
* 3. Code Node → transform to array of HardcoverBook objects
* 4. HTTP Request → POST https://dk0.dev/api/n8n/hardcover/sync-books
* Headers: Authorization: Bearer <N8N_SECRET_TOKEN>
* Body: [{ hardcover_id, title, author, image, rating, finished_at }, ...]
*
* Expected body shape (array or single object):
* {
* hardcover_id: string | number // Hardcover book ID, used for deduplication
* title: string
* author: string
* image?: string // Cover image URL
* rating?: number // 15
* finished_at?: string // ISO date string
* }
*/
import { NextRequest, NextResponse } from 'next/server';
import { getBookReviewByHardcoverId, createBookReview } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
interface HardcoverBook {
hardcover_id: string | number;
title: string;
author: string;
image?: string;
rating?: number;
finished_at?: string;
}
export async function POST(request: NextRequest) {
// Auth: require N8N_SECRET_TOKEN or N8N_API_KEY
const authHeader = request.headers.get('Authorization');
const apiKeyHeader = request.headers.get('X-API-Key');
const validToken = process.env.N8N_SECRET_TOKEN;
const validApiKey = process.env.N8N_API_KEY;
const isAuthenticated =
(validToken && authHeader === `Bearer ${validToken}`) ||
(validApiKey && apiKeyHeader === validApiKey);
if (!isAuthenticated) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limit: max 10 sync requests per minute
const ip = getClientIp(request);
if (!checkRateLimit(ip, 10, 60000, 'hardcover-sync')) {
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
let books: HardcoverBook[];
try {
const body = await request.json();
books = Array.isArray(body) ? body : [body];
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (books.length === 0) {
return NextResponse.json({ success: true, created: 0, skipped: 0, errors: 0 });
}
const results = {
created: 0,
skipped: 0,
errors: 0,
details: [] as string[],
};
for (const book of books) {
if (!book.title || !book.author) {
results.errors++;
results.details.push(`Skipped (missing title/author): ${JSON.stringify(book).slice(0, 80)}`);
continue;
}
const hardcoverId = String(book.hardcover_id);
// Deduplication: skip if already in Directus
const existing = await getBookReviewByHardcoverId(hardcoverId);
if (existing) {
results.skipped++;
results.details.push(`Skipped (exists): "${book.title}"`);
continue;
}
// Create new entry in Directus
const created = await createBookReview({
hardcover_id: hardcoverId,
book_title: book.title,
book_author: book.author,
book_image: book.image,
rating: book.rating,
finished_at: book.finished_at,
status: 'published',
});
if (created) {
results.created++;
results.details.push(`Created: "${book.title}" → id=${created.id}`);
} else {
results.errors++;
results.details.push(`Error creating: "${book.title}" (Directus unavailable or token missing)`);
}
}
if (process.env.NODE_ENV === 'development') {
console.log('[sync-books]', results);
}
return NextResponse.json({ success: true, source: 'directus', ...results });
}

View File

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

View File

@@ -42,11 +42,13 @@ export async function PUT(
locale?: string; locale?: string;
title?: string; title?: string;
description?: string; description?: string;
content?: string;
}; };
const locale = body.locale || "en"; const locale = body.locale || "en";
const title = body.title?.trim(); const title = body.title?.trim();
const description = body.description?.trim(); const description = body.description?.trim();
const content = typeof body.content === "string" ? body.content.trim() : undefined;
if (!title || !description) { if (!title || !description) {
return NextResponse.json({ error: "title and description are required" }, { status: 400 }); return NextResponse.json({ error: "title and description are required" }, { status: 400 });
@@ -59,10 +61,12 @@ export async function PUT(
locale, locale,
title, title,
description, description,
content: content ?? undefined,
}, },
update: { update: {
title, title,
description, description,
content: content ?? undefined,
}, },
}); });

View File

@@ -4,6 +4,8 @@ import { apiCache } from '@/lib/cache';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp } from '@/lib/auth'; import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp } from '@/lib/auth';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
import { generateUniqueSlug } from '@/lib/slug'; import { generateUniqueSlug } from '@/lib/slug';
import { getProjects as getDirectusProjects } from '@/lib/directus';
import { ProjectListItem } from '@/app/_ui/ProjectsPageClient';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -40,46 +42,80 @@ export async function GET(request: NextRequest) {
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50; const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
const category = searchParams.get('category'); const category = searchParams.get('category');
const featured = searchParams.get('featured'); 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 difficulty = searchParams.get('difficulty');
const search = searchParams.get('search'); const search = searchParams.get('search');
const locale = searchParams.get('locale') || 'en';
// Create cache parameters object // Try Directus FIRST (Primary Source)
const cacheParams = { let directusProjects: ProjectListItem[] = [];
page: page.toString(), let directusSuccess = false;
limit: limit.toString(), try {
category, const fetched = await getDirectusProjects(locale, {
featured, featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
published, published: published,
difficulty, category: category || undefined,
search difficulty: difficulty || undefined,
}; search: search || undefined,
limit
});
// Check cache first if (fetched) {
const cached = await apiCache.getProjects(cacheParams); directusProjects = fetched.map(p => ({
if (cached && !search) { // Don't cache search results id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
return NextResponse.json(cached); 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 skip = (page - 1) * limit;
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (category) where.category = category; if (category) where.category = category;
if (featured !== null) where.featured = featured === 'true'; if (featured !== null) where.featured = featured === 'true';
if (published !== null) where.published = published === 'true'; where.published = published;
if (difficulty) where.difficulty = difficulty; if (difficulty) where.difficulty = difficulty;
if (search) { if (search) {
where.OR = [ where.OR = [
{ title: { contains: search, mode: 'insensitive' } }, { title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } },
{ tags: { hasSome: [search] } }, { tags: { hasSome: [search] } }
{ content: { contains: search, mode: 'insensitive' } }
]; ];
} }
const [projects, total] = await Promise.all([ const [dbProjects, total] = await Promise.all([
prisma.project.findMany({ prisma.project.findMany({
where, where,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -89,19 +125,31 @@ export async function GET(request: NextRequest) {
prisma.project.count({ where }) prisma.project.count({ where })
]); ]);
const result = { // Merge logic
projects, const dbSlugs = new Set(dbProjects.map(p => p.slug));
total, const mergedProjects: ProjectListItem[] = dbProjects.map(p => ({
pages: Math.ceil(total / limit), id: p.id,
currentPage: page 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,
}));
// Cache the result (only for non-search queries) for (const dp of directusProjects) {
if (!search) { if (!dbSlugs.has(dp.slug)) {
await apiCache.setProjects(cacheParams, result); mergedProjects.push(dp);
}
} }
return NextResponse.json(result); return NextResponse.json({
projects: mergedProjects,
total: total + (mergedProjects.length - dbProjects.length),
source: 'merged'
});
} catch (error) { } catch (error) {
// Handle missing database table gracefully // Handle missing database table gracefully
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') { if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma'; import { getProjects } from '@/lib/directus';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -7,56 +7,27 @@ export async function GET(request: NextRequest) {
const slug = searchParams.get('slug'); const slug = searchParams.get('slug');
const search = searchParams.get('search'); const search = searchParams.get('search');
const category = searchParams.get('category'); const category = searchParams.get('category');
const locale = searchParams.get('locale') || 'en';
if (slug) { // Use Directus instead of Prisma
const project = await prisma.project.findFirst({ const projects = await getProjects(locale, {
where: { featured: undefined,
published: true, published: true,
slug, category: category && category !== 'All' ? category : undefined,
}, search: search || undefined,
orderBy: { createdAt: 'desc' },
}); });
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 = projects.find(p => p.slug === slug);
return NextResponse.json({ projects: project ? [project] : [] }); 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 }); return NextResponse.json({ projects });
} catch (error) { } catch (error) {
console.error('Error searching projects:', error); console.error('Error searching projects:', error);

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,361 @@
"use client"; "use client";
import { motion, Variants } from "framer-motion"; import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react"; import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic";
import RichTextClient from "./RichTextClient"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading"; 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 = { const iconMap: Record<string, LucideIcon> = {
hidden: { opacity: 0 }, Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
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 About = () => { const About = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.about"); const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | 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(() => { useEffect(() => {
(async () => { const fetchData = async () => {
try { try {
const res = await fetch( const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`, fetch(`/api/content/page?key=home-about&locale=${locale}`),
); fetch(`/api/tech-stack?locale=${locale}`),
const data = await res.json(); fetch(`/api/hobbies?locale=${locale}`),
// Only use CMS content if it exists for the active locale. fetch(`/api/messages?locale=${locale}`),
if (data?.content?.content && data?.content?.locale === locale) { fetch(`/api/snippets?limit=3&featured=true`)
setCmsDoc(data.content.content as JSONContent); ]);
} else {
setCmsDoc(null); const cmsData = await cmsRes.json();
if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
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);
} }
} catch { };
// ignore; fallback to static fetchData();
setCmsDoc(null);
}
})();
}, [locale]); }, [locale]);
const techStack = [ const copyToClipboard = (code: string) => {
{ navigator.clipboard.writeText(code);
category: t("techStack.categories.frontendMobile"), setCopied(true);
icon: Globe, setTimeout(() => setCopied(false), 2000);
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") },
];
return ( return (
<section <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">
id="about" <div className="max-w-7xl mx-auto">
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"
>
<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>
</div>
</motion.div>
</motion.div>
</motion.div>
{/* Tech Stack & Hobbies */} <div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={staggerContainer}
className="space-y-8"
>
<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="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}
</span>
))}
</div>
</motion.div>
))}
</div>
</div>
{/* Hobbies */} {/* 1. Large Bio Text */}
<div> <motion.div
<motion.h3 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"
variants={fadeInUp}
className="text-xl font-bold text-stone-900 mb-4"
> >
{t("hobbiesTitle")} <div className="space-y-5 sm:space-y-6 md:space-y-8">
</motion.h3> <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"> <div className="space-y-3">
{hobbies.map((hobby, idx) => ( <Skeleton className="h-6 w-full" />
<motion.div <Skeleton className="h-6 w-[95%]" />
key={`hobby-${hobby.text}-${idx}`} <Skeleton className="h-6 w-[90%]" />
variants={fadeInUp} </div>
whileHover={{ ) : cmsHtml ? (
x: 8, <RichTextClient html={cmsHtml} />
scale: 1.02, ) : (
transition: { duration: 0.4, ease: "easeOut" }, <p>{t("p1")} {t("p2")}</p>
}} )}
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out ${ </div>
idx === 0 <div className="pt-4 sm:pt-6 md:pt-8">
? "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" <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">
: idx === 1 <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>
? "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" {isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
: idx === 2 </div>
? "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" </div>
: "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" </div>
}`}
>
<hobby.icon size={20} className="text-stone-600" />
<span className="text-stone-700 font-medium">
{hobby.text}
</span>
</motion.div> </motion.div>
{/* 2. Activity / Status Box */}
<motion.div
transition={{ delay: 0.1 }}
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
>
<div className="relative z-10 h-full">
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> Status
</h3>
<ActivityFeed locale={locale} />
</div>
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
</motion.div>
{/* 3. AI Chat Box */}
<motion.div
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
transition={{ delay: 0.3 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-6">
<Skeleton className="h-3 w-20" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-8 w-24 rounded-xl" />
<Skeleton className="h-8 w-16 rounded-xl" />
<Skeleton className="h-8 w-20 rounded-xl" />
</div>
</div>
))
) : (
techStack.map((cat) => (
<div key={cat.id} className="space-y-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
<div className="flex flex-wrap gap-2">
{cat.items?.map((item: TechStackItem) => (
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
{item.name}
</span>
))} ))}
</div> </div>
</div> </div>
))
)}
</div>
</motion.div>
{/* Currently Reading */} {/* 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 <motion.div
variants={fadeInUp} transition={{ delay: 0.4 }}
className="mt-8" className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
> >
<div className="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 /> <CurrentlyReading />
<div className="mt-6 flex-1">
<ReadBooks />
</div>
</div>
</motion.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
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
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> </motion.div>
</div> </div>
</div> </div>
{/* 6. Hobbies */}
<motion.div
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> </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,13 @@ import { usePathname } from "next/navigation";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ToastProvider } from "@/components/Toast"; import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary"; import ErrorBoundary from "@/components/ErrorBoundary";
import { AnalyticsProvider } from "@/components/AnalyticsProvider"; import { ConsentProvider } from "./ConsentProvider";
import { ConsentProvider, useConsent } from "./ConsentProvider"; import { ThemeProvider } from "./ThemeProvider";
// Dynamic import with SSR disabled to avoid framer-motion issues const BackgroundBlobs = dynamic(
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), { () => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
ssr: false, { ssr: false, loading: () => null }
loading: () => null, );
});
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
export default function ClientProviders({ export default function ClientProviders({
children, children,
@@ -25,56 +19,21 @@ export default function ClientProviders({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [is404Page, setIs404Page] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
// Check if we're on a 404 page by looking for the data attribute or pathname
const check404 = () => {
try {
if (typeof window !== "undefined" && typeof document !== "undefined") {
const has404Component = document.querySelector('[data-404-page]');
const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
setIs404Page(!!has404Component || is404Path);
}
} catch (error) {
// Silently fail - 404 detection is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error checking 404 status:', error);
}
}
};
// Check immediately and after a short delay
try {
check404();
const timeout = setTimeout(check404, 100);
const interval = setInterval(check404, 500);
return () => {
try {
clearTimeout(timeout);
clearInterval(interval);
} catch {
// Silently fail during cleanup
}
};
} catch (error) {
// If setup fails, just return empty cleanup
if (process.env.NODE_ENV === 'development') {
console.warn('Error setting up 404 check:', error);
}
return () => {};
}
}, [pathname]); }, [pathname]);
// Wrap in multiple error boundaries to isolate failures
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ErrorBoundary> <ErrorBoundary>
<ConsentProvider> <ConsentProvider>
<GatedProviders mounted={mounted} is404Page={is404Page}> <ThemeProvider>
<GatedProviders mounted={mounted}>
{children} {children}
</GatedProviders> </GatedProviders>
</ThemeProvider>
</ConsentProvider> </ConsentProvider>
</ErrorBoundary> </ErrorBoundary>
</ErrorBoundary> </ErrorBoundary>
@@ -84,30 +43,30 @@ export default function ClientProviders({
function GatedProviders({ function GatedProviders({
children, children,
mounted, mounted,
is404Page,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
mounted: boolean; mounted: boolean;
is404Page: boolean;
}) { }) {
const { consent } = useConsent(); // Defer animated background blobs until after LCP
const pathname = usePathname(); const [deferredReady, setDeferredReady] = useState(false);
useEffect(() => {
if (!mounted) return;
let id: ReturnType<typeof setTimeout> | number;
if (typeof requestIdleCallback !== "undefined") {
id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 });
return () => cancelIdleCallback(id as number);
} else {
id = setTimeout(() => setDeferredReady(true), 200);
return () => clearTimeout(id);
}
}, [mounted]);
const isAdminRoute = pathname.startsWith("/manage") || pathname.startsWith("/editor"); return (
// If consent is not decided yet, treat optional features as off
const analyticsEnabled = !!consent?.analytics;
const chatEnabled = !!consent?.chat;
const content = (
<ErrorBoundary> <ErrorBoundary>
<ToastProvider> <ToastProvider>
{mounted && <BackgroundBlobs />} {deferredReady && <BackgroundBlobs />}
<div className="relative z-10">{children}</div> <div className="relative z-10">{children}</div>
{mounted && !is404Page && !isAdminRoute && chatEnabled && <ChatWidget />}
</ToastProvider> </ToastProvider>
</ErrorBoundary> </ErrorBoundary>
); );
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
} }

View File

@@ -0,0 +1,96 @@
"use client";
/**
* Transitional Wrapper für bestehende Components
* Nutzt direkt JSON Messages statt komplexe Translation-Loader
*/
import { NextIntlClientProvider } from 'next-intl';
import dynamic from 'next/dynamic';
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
// SSR stays on (default) so content is in the initial HTML for SEO.
const About = dynamic(() => import('./About'));
const Projects = dynamic(() => import('./Projects'));
const Contact = dynamic(() => import('./Contact'));
const Footer = dynamic(() => import('./Footer'));
import type {
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 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() { export default function ConsentBanner() {
const { consent, ready, setConsent } = useConsent(); 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 [minimized, setMinimized] = useState(false);
const t = useTranslations("consent"); const t = useTranslations("consent");
@@ -19,7 +19,6 @@ export default function ConsentBanner() {
title: t("title"), title: t("title"),
description: t("description"), description: t("description"),
essential: t("essential"), essential: t("essential"),
analytics: t("analytics"),
chat: t("chat"), chat: t("chat"),
alwaysOn: t("alwaysOn"), alwaysOn: t("alwaysOn"),
acceptAll: t("acceptAll"), acceptAll: t("acceptAll"),
@@ -55,8 +54,6 @@ export default function ConsentBanner() {
type="button" type="button"
onClick={() => setMinimized(true)} onClick={() => setMinimized(true)}
className="shrink-0 text-xs text-stone-500 hover:text-stone-900 transition-colors" className="shrink-0 text-xs text-stone-500 hover:text-stone-900 transition-colors"
aria-label="Minimize privacy banner"
title="Minimize"
> >
{s.hide} {s.hide}
</button> </button>
@@ -68,16 +65,6 @@ export default function ConsentBanner() {
<div className="text-[11px] text-stone-500">{s.alwaysOn}</div> <div className="text-[11px] text-stone-500">{s.alwaysOn}</div>
</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"> <label className="flex items-center justify-between gap-3 py-1">
<span className="text-sm font-semibold text-stone-800">{s.chat}</span> <span className="text-sm font-semibold text-stone-800">{s.chat}</span>
<input <input
@@ -91,7 +78,7 @@ export default function ConsentBanner() {
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
<button <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" className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
> >
{s.acceptAll} {s.acceptAll}
@@ -103,7 +90,7 @@ export default function ConsentBanner() {
{s.acceptSelected} {s.acceptSelected}
</button> </button>
<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" className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
> >
{s.rejectAll} {s.rejectAll}

View File

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

View File

@@ -2,11 +2,11 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion } from "framer-motion"; 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 { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic";
import RichTextClient from "./RichTextClient"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
const Contact = () => { const Contact = () => {
const { showEmailSent, showEmailError } = useToast(); const { showEmailSent, showEmailError } = useToast();
@@ -14,7 +14,7 @@ const Contact = () => {
const t = useTranslations("home.contact"); const t = useTranslations("home.contact");
const tForm = useTranslations("home.contact.form"); const tForm = useTranslations("home.contact.form");
const tInfo = useTranslations("home.contact.info"); const tInfo = useTranslations("home.contact.info");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -24,14 +24,14 @@ const Contact = () => {
); );
const data = await res.json(); const data = await res.json();
// Only use CMS content if it exists for the active locale. // Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) { if (data?.content?.html && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent); setCmsHtml(data.content.html as string);
} else { } else {
setCmsDoc(null); setCmsHtml(null);
} }
} catch { } catch {
// ignore; fallback to static // ignore; fallback to static
setCmsDoc(null); setCmsHtml(null);
} }
})(); })();
}, [locale]); }, [locale]);
@@ -152,118 +152,111 @@ const Contact = () => {
validateForm(); 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 ( return (
<section <section
id="contact" 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"> <div className="max-w-7xl mx-auto">
{/* Section Header */} <div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* Header Card */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} 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"
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"> <div className="max-w-3xl">
{t("title")} <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> </h2>
{cmsDoc ? ( {cmsHtml ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" /> <RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
) : ( ) : (
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4"> <p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")} {t("subtitle")}
</p> </p>
)} )}
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact Information */}
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
className="space-y-8"
>
<div>
<h3 className="text-2xl font-bold text-stone-900 mb-6">
{t("getInTouch")}
</h3>
<p className="text-stone-700 leading-relaxed">
{t("getInTouchBody")}
</p>
</div>
{/* Contact Details */}
<div className="space-y-4">
{contactInfo.map((info, index) => (
<motion.a
key={info.title}
href={info.href}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{
duration: 0.5,
delay: index * 0.1,
ease: [0.25, 0.1, 0.25, 1],
}}
whileHover={{
x: 8,
transition: { duration: 0.4, ease: "easeOut" },
}}
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-[background-color,border-color,box-shadow] duration-500 ease-out group border-transparent hover:border-white/70"
>
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
<info.icon className="w-6 h-6 text-stone-700" />
</div>
<div>
<h4 className="font-semibold text-stone-800">
{info.title}
</h4>
<p className="text-stone-500">{info.value}</p>
</div>
</motion.a>
))}
</div> </div>
</motion.div> </motion.div>
{/* Contact Form */} {/* Info Side (Unified Connect Box) */}
<motion.div <motion.div
initial={{ opacity: 0, x: 20 }} transition={{ delay: 0.1 }}
whileInView={{ opacity: 1, x: 0 }} className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
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"
> >
<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
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")} {tForm("title")}
</h3> </h3>
<form onSubmit={handleSubmit} className="space-y-6"> <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-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div> <div className="space-y-2">
<label <label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
htmlFor="name" {tForm("labels.name")}
className="block text-sm font-medium text-stone-600 mb-2"
>
Name <span className="text-liquid-rose">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -273,32 +266,14 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required 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 ${ 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"
errors.name && touched.name
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
placeholder={tForm("placeholders.name")} 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>
<div> <div className="space-y-2">
<label <label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
htmlFor="email" {tForm("labels.email")}
className="block text-sm font-medium text-stone-600 mb-2"
>
Email <span className="text-liquid-rose">*</span>
</label> </label>
<input <input
type="email" type="email"
@@ -308,33 +283,15 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required 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 ${ 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"
errors.email && touched.email
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
placeholder={tForm("placeholders.email")} 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> </div>
<div> <div className="space-y-2">
<label <label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
htmlFor="subject" {tForm("labels.subject")}
className="block text-sm font-medium text-stone-600 mb-2"
>
Subject <span className="text-liquid-rose">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -344,34 +301,14 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required 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 ${ 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"
errors.subject && touched.subject
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
placeholder={tForm("placeholders.subject")} 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>
<div> <div className="space-y-2">
<label <label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
htmlFor="message" {tForm("labels.message")}
className="block text-sm font-medium text-stone-600 mb-2"
>
Message <span className="text-liquid-rose">*</span>
</label> </label>
<textarea <textarea
id="message" id="message"
@@ -380,53 +317,25 @@ const Contact = () => {
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
required required
rows={6} rows={5}
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 ${ 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"
errors.message && touched.message
? "border-red-400 focus:ring-red-400"
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
}`}
placeholder={tForm("placeholders.message")} 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> </div>
<motion.button <motion.button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}} whileHover={{ scale: 1.01 }}
whileTap={!isSubmitting ? { scale: 0.98 } : {}} whileTap={{ scale: 0.99 }}
transition={{ duration: 0.3, ease: "easeOut" }} 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"
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"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>{tForm("sending")}</span>
</>
) : ( ) : (
<> <>
<Send size={20} /> <Send size={16} />
<span className="text-cream">{tForm("send")}</span> {tForm("send")}
</> </>
)} )}
</motion.button> </motion.button>

View File

@@ -4,6 +4,8 @@ import { motion } from "framer-motion";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image";
import { Skeleton } from "./ui/Skeleton";
interface CurrentlyReading { interface CurrentlyReading {
title: string; title: string;
@@ -53,8 +55,26 @@ const CurrentlyReading = () => {
fetchCurrentlyReading(); fetchCurrentlyReading();
}, []); // Leeres Array = nur einmal beim Mount }, []); // 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 // Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
if (loading || books.length === 0) { if (books.length === 0) {
return null; return null;
} }
@@ -62,8 +82,8 @@ const CurrentlyReading = () => {
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<BookOpen size={18} className="text-stone-600 flex-shrink-0" /> <BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
<h3 className="text-lg font-bold text-stone-900"> <h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
{t("title")} {books.length > 1 && `(${books.length})`} {t("title")} {books.length > 1 && `(${books.length})`}
</h3> </h3>
</div> </div>
@@ -80,11 +100,11 @@ const CurrentlyReading = () => {
scale: 1.02, scale: 1.02,
transition: { duration: 0.4, ease: "easeOut" }, 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 */} {/* Background Blob Animation */}
<motion.div <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={{ animate={{
scale: [1, 1.2, 1], scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3], opacity: [0.3, 0.5, 0.3],
@@ -106,12 +126,13 @@ const CurrentlyReading = () => {
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }} transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
className="flex-shrink-0" 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"> <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">
<img <Image
src={book.image} src={book.image}
alt={book.title} alt={book.title}
className="w-full h-full object-cover" fill
loading="lazy" className="object-cover"
sizes="(max-width: 640px) 96px, 112px"
/> />
{/* Glossy Overlay */} {/* Glossy Overlay */}
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" /> <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 */} {/* Book Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Title */} {/* 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} {book.title}
</h4> </h4>
{/* Authors */} {/* 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(", ")} {book.authors.join(", ")}
</p> </p>
{/* Progress Bar */} {/* Progress Bar */}
<div className="space-y-2"> <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>{t("progress")}</span>
<span className="font-semibold">{book.progress}%</span> <span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
</div> </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 <motion.div
initial={{ width: 0 }} 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" }} 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" 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"; "use client";
import { useState } from 'react'; import React from "react";
import { motion } from 'framer-motion'; import Link from "next/link";
import { Heart, Code } from 'lucide-react';
import { SiGithub, SiLinkedin } from 'react-icons/si';
import Link from 'next/link';
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useConsent } from "./ConsentProvider"; import { ArrowUp } from "lucide-react";
const Footer = () => { const Footer = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("footer"); const t = useTranslations("footer");
const { resetConsent } = useConsent(); const year = new Date().getFullYear();
const [currentYear] = useState(() => new Date().getFullYear()); const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
const socialLinks = [ };
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
];
return ( 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="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 */} <div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
<motion.div {/* Copyright & Info */}
initial={{ opacity: 0, y: 10 }} <div className="md:col-span-4 space-y-6">
whileInView={{ opacity: 1, y: 0 }} <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">
viewport={{ once: true, margin: "-50px" }} dk
transition={{ duration: 0.4 }} </div>
className="flex items-center space-x-3" <div className="space-y-2">
> <p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
<motion.div <p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
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> </div>
</motion.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>
{/* 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 }}
>
<Heart size={14} className="text-liquid-rose fill-liquid-rose" />
</motion.div>
<span>{t("madeIn")}</span>
</motion.div>
</div> </div>
{/* Legal Links */} {/* Navigation Links */}
<motion.div <div className="md:col-span-4 grid grid-cols-2 gap-8">
initial={{ opacity: 0, y: 10 }} <div className="space-y-4">
whileInView={{ opacity: 1, y: 0 }} <p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
viewport={{ once: true, margin: "-50px" }} <div className="flex flex-col gap-2">
transition={{ duration: 0.4, delay: 0.15 }} <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>
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" <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 className="flex space-x-6 text-sm"> </div>
<Link <div className="space-y-4">
href={`/${locale}/legal-notice`} <p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
className="text-stone-500 hover:text-stone-800 transition-colors duration-200" <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>
{t("legalNotice")} <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>
</Link> </div>
<Link </div>
href={`/${locale}/privacy-policy`} </div>
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
> {/* Back to Top */}
{t("privacyPolicy")} <div className="md:col-span-4 flex justify-start md:justify-end">
</Link>
<button <button
type="button" onClick={scrollToTop}
onClick={() => resetConsent()} 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"
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
title={t("privacySettingsTitle")}
> >
{t("privacySettings")} <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> </button>
<Link </div>
href="/404"
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"
title="Kernel Panic 404"
>
404
</Link>
</div> </div>
<div className="text-xs text-stone-400 flex items-center space-x-1"> {/* Bottom Bar */}
<span>{t("builtWith")}</span> <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">
<span className="text-stone-600 font-semibold">Next.js</span> <p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
<span className="text-stone-300"></span> Built with Next.js, Directus & Passion.
<span className="text-stone-600 font-semibold">TypeScript</span> </p>
<span className="text-stone-300"></span> <div className="flex items-center gap-2">
<span className="text-stone-600 font-semibold">Tailwind CSS</span> <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> </div>
</motion.div>
</div> </div>
</footer> </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,20 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { Menu, X } from "lucide-react";
import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname } from "next/navigation";
import { ThemeToggle } from "./ThemeToggle";
const Header = () => { const Header = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams();
const t = useTranslations("nav"); const t = useTranslations("nav");
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`; const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
useEffect(() => {
const handleScroll = () => {
setScrolled(window.scrollY > 50);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const navItems = [ const navItems = [
{ name: t("home"), href: `/${locale}` }, { name: t("home"), href: `/${locale}` },
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` }, { name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
@@ -34,232 +22,75 @@ const Header = () => {
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` }, { 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 ( return (
<> <>
<motion.header <div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
initial={false} <nav className="animate-slide-down 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">
animate={{ y: 0, opacity: 1 }} {/* Logo Pill */}
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
${
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
`}
>
<motion.div
whileHover={{ scale: 1.05 }}
className="flex items-center space-x-2"
>
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center" 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"
> >
dk<span className="text-red-500">0</span> <span className="font-black text-xs tracking-tighter">dk</span>
</Link> </Link>
</motion.div>
<nav className="hidden md:flex items-center space-x-8"> {/* Desktop Menu */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => ( {navItems.map((item) => (
<motion.div
key={item.name}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
>
<Link <Link
key={item.name}
href={item.href} 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" 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"
onClick={(e) => {
if (item.href.startsWith("#")) {
e.preventDefault();
const element = document.querySelector(item.href);
if (element) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}
}}
> >
{item.name} {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> </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
</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> </div>
<motion.button <div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
whileTap={{ scale: 0.95 }}
{/* 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)} onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover" 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"
aria-label={isOpen ? "Close menu" : "Open menu"}
> >
{isOpen ? <X size={24} /> : <Menu size={24} />} {isOpen ? <X size={14} /> : <Menu size={14} />}
</motion.button> </button>
</motion.div> </div>
</nav>
</div> </div>
<AnimatePresence> {/* Mobile Menu Overlay */}
{isOpen && ( <div
<> 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 transition-all duration-200 ${
<motion.div isOpen
initial={{ opacity: 0 }} ? "opacity-100 translate-y-0 pointer-events-auto"
animate={{ opacity: 1 }} : "opacity-0 -translate-y-2 pointer-events-none"
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 }}
> >
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<Link <Link
key={item.name}
href={item.href} href={item.href}
onClick={(e) => { onClick={() => setIsOpen(false)}
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"
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} {item.name}
</Link> </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> </div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</motion.header>
</> </>
); );
}; };

View File

@@ -0,0 +1,239 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations";
// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB)
const MenuIcon = ({ size = 24 }: { size?: number }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
);
const XIcon = ({ size = 24 }: { size?: number }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
);
const MailIcon = ({ size = 20 }: { size?: number }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
);
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 prevLocale = useRef(locale);
useEffect(() => {
if (prevLocale.current !== locale) {
window.scrollTo({ top: 0, behavior: "instant" });
prevLocale.current = locale;
}
}, [locale]);
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: MailIcon, 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 (
<>
<header 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={`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"
}`}
>
<div className="flex items-center space-x-2 hover:scale-105 transition-transform">
<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>
</div>
<nav className="hidden md:flex items-center space-x-8">
{navItems.map((item) => (
<div key={item.name} className="hover:-translate-y-0.5 active:scale-95 transition-all">
<Link
href={item.href}
className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
>
{item.name}
</Link>
</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>
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-all hover:scale-105 active:scale-95"
aria-label="Toggle menu"
>
{isOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
</button>
</div>
</div>
</header>
{/* Mobile menu overlay */}
<div
className={`fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden transition-opacity duration-200 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={() => setIsOpen(false)}
/>
{/* Mobile menu panel */}
<div
className={`fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto transition-transform duration-300 ease-out ${
isOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<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"
>
<XIcon 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>
</div>
</>
);
}

View File

@@ -1,253 +1,73 @@
"use client"; import { getTranslations } from "next-intl/server";
import Image from "next/image";
import { motion } from "framer-motion"; interface HeroProps {
import { ArrowDown, Code, Zap, Rocket } from "lucide-react"; locale: string;
import { useEffect, useState } from "react"; }
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
const Hero = () => { export default async function Hero({ locale }: HeroProps) {
const locale = useLocale(); const t = await getTranslations({ locale, namespace: "home.hero" });
const t = useTranslations("home.hero");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
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);
}
} catch {
// ignore; fallback to static
setCmsDoc(null);
}
})();
}, [locale]);
const features = [
{ icon: Code, text: t("features.f1") },
{ icon: Zap, text: t("features.f2") },
{ icon: Rocket, text: t("features.f3") },
];
return ( 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"> <section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto"> {/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
{/* Profile Image with Organic Blob Mask */} <div className="absolute inset-0 pointer-events-none overflow-hidden">
<motion.div <div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
initial={{ opacity: 0, scale: 0.9 }} <div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
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",
}}
/>
{/* 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"
/>
{/* 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 */}
<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"
>
<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> </div>
</motion.div>
{/* Floating Badges - subtle animations */} <div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20 pb-10 sm:pb-16">
<motion.div <div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} {/* Left: Text Content */}
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }} <div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
whileHover={{ scale: 1.1, rotate: 5 }} <div 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 animate-[fadeIn_0.5s_ease-out]">
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" <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">{t("badge")}</span>
<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} />
</motion.div>
</div> </div>
</motion.div>
{/* Main Title */} <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.div <span className="block">
initial={{ opacity: 0, y: 20 }} {t("line1")}
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> </span>
</motion.div> <span 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">
))} {t("line2")}
</motion.div> </span>
</h1>
{/* CTA Buttons */} <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 xl:mx-0 font-light leading-relaxed tracking-tight">
<motion.div {t("description")}
initial={{ opacity: 0, y: 20 }} </p>
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"
>
<span className="text-cream">{t("ctaWork")}</span>
<ArrowDown size={18} />
</motion.a>
<motion.a <div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center xl:justify-start pt-2 sm:pt-4 animate-[fadeIn_0.6s_ease-out_0.3s_both]">
href="#contact" <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">
whileHover={{ scale: 1.03, y: -2 }} <div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
whileTap={{ scale: 0.98 }} {t("ctaWork")}
transition={{ duration: 0.3, ease: "easeOut" }} </a>
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" <a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-700 dark:text-stone-300 hover:text-stone-900 dark:hover:text-white transition-colors">
> {t("ctaContact")}
<span>{t("ctaContact")}</span> </a>
</motion.a> </div>
</motion.div> </div>
{/* Right: The Photo */}
<div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 xl:w-[500px] xl:h-[500px] shrink-0 mt-4 sm:mt-8 xl:mt-0 animate-[fadeIn_1s_ease-out]">
<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: 1280px) 320px, 500px" />
</div>
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
<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>
</div>
</div>
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4 animate-bounce">
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
</div> </div>
</section> </section>
); );
}; }
export default Hero;

View File

@@ -1,34 +1,12 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion, Variants } from "framer-motion"; import { motion } from "framer-motion";
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react"; import { ArrowUpRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Skeleton } from "./ui/Skeleton";
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,
},
},
};
interface Project { interface Project {
id: number; id: number;
@@ -47,215 +25,106 @@ interface Project {
const Projects = () => { const Projects = () => {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.projects"); useTranslations("home.projects");
useEffect(() => { useEffect(() => {
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const response = await fetch( const response = await fetch("/api/projects?featured=true&published=true&limit=6");
"/api/projects?featured=true&published=true&limit=6",
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setProjects(data.projects || []); setProjects(data.projects || []);
} }
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { console.error("Featured projects fetch failed:", error);
console.error("Error loading projects:", error); } finally {
} setLoading(false);
} }
}; };
loadProjects(); loadProjects();
}, []); }, []);
return ( return (
<section <section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
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"
>
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<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">
initial="hidden" <div>
whileInView="visible" <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">
viewport={{ once: true, margin: "-50px" }} Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
variants={fadeInUp}
className="text-center mb-20"
>
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
{t("title")}
</h2> </h2>
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light"> <p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
{t("subtitle")} Projects that pushed my boundaries.
</p> </p>
</motion.div> </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 <div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
initial="hidden" {loading ? (
whileInView="visible" Array.from({ length: 2 }).map((_, i) => (
viewport={{ once: true, margin: "-50px" }} <div key={i} className="space-y-6">
variants={staggerContainer} <Skeleton className="aspect-[4/3] rounded-[2.5rem]" />
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8" <div className="space-y-3">
> <Skeleton className="h-8 w-1/2" />
{projects.map((project) => ( <Skeleton className="h-4 w-3/4" />
</div>
</div>
))
) : projects.length === 0 ? (
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
No projects yet.
</div>
) : (
projects.map((project) => (
<motion.div <motion.div
key={project.id} key={project.id}
variants={fadeInUp} className="group relative"
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"
> >
{/* Project Cover / Image Area */} <Link href={`/${locale}/projects/${project.slug}`} className="block">
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100"> {/* 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 ? ( {project.imageUrl ? (
<>
<Image <Image
src={project.imageUrl} src={project.imageUrl}
alt={project.title} alt={project.title}
fill 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 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" /> <span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
<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> </div>
)} )}
{/* Overlay on Hover */}
{/* Texture/Grain Overlay */} <div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
<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> </div>
{/* Content */} {/* Text Content */}
<div className="p-6 flex flex-col flex-1"> <div className="flex justify-between items-start">
{/* Stretched Link covering the whole card (including image area) */} <div>
<Link <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">
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} {project.title}
</h3> </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"> <p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
<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} {project.description}
</p> </p>
</div>
<div className="flex flex-wrap gap-2 mb-6"> <div className="hidden sm:flex gap-2">
{project.tags.slice(0, 4).map((tag) => ( {project.tags.slice(0, 2).map(tag => (
<span <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">
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} {tag}
</span> </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> </div>
</div>
</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> </Link>
</motion.div> </motion.div>
)))}
</div>
</div> </div>
</section> </section>
); );

View File

@@ -0,0 +1,246 @@
"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 (
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
<BookCheck size={16} className="shrink-0" />
<span>{t("empty")}</span>
</div>
);
}
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

@@ -1,22 +1,19 @@
"use client"; "use client";
import React, { useMemo } from "react"; import React from "react";
import type { JSONContent } from "@tiptap/react";
import { richTextToSafeHtml } from "@/lib/richtext";
// Accepts pre-sanitized HTML string (converted server-side via richTextToSafeHtml).
// This keeps TipTap/ProseMirror out of the client bundle entirely.
export default function RichTextClient({ export default function RichTextClient({
doc, html,
className, className,
}: { }: {
doc: JSONContent; html: string;
className?: string; className?: string;
}) { }) {
const html = useMemo(() => richTextToSafeHtml(doc), [doc]);
return ( return (
<div <div
className={className} className={className}
// HTML is sanitized in `richTextToSafeHtml`
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
); );

View File

@@ -2,13 +2,6 @@
import React, { useEffect, useState } from "react"; 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(() => const ToastProvider = React.lazy(() =>
import("@/components/Toast").then((mod) => ({ import("@/components/Toast").then((mod) => ({
default: mod.ToastProvider, default: mod.ToastProvider,
@@ -38,14 +31,11 @@ export default function RootProviders({
return ( return (
<React.Suspense fallback={<div className="relative z-10">{children}</div>}> <React.Suspense fallback={<div className="relative z-10">{children}</div>}>
<AnalyticsProvider>
<ToastProvider> <ToastProvider>
<BackgroundBlobs /> <BackgroundBlobs />
<div className="relative z-10">{children}</div> <div className="relative z-10">{children}</div>
<ChatWidget /> <ChatWidget />
</ToastProvider> </ToastProvider>
</AnalyticsProvider>
</React.Suspense> </React.Suspense>
); );
} }

View File

@@ -0,0 +1,60 @@
"use client";
import { useRef, useEffect, useState, type ReactNode } from "react";
interface ScrollFadeInProps {
children: ReactNode;
className?: string;
delay?: number;
}
/**
* Wraps children in a fade-in-up animation triggered by scroll.
* Unlike Framer Motion's initial={{ opacity: 0 }}, this does NOT
* render opacity:0 in SSR HTML — content is visible by default
* and only hidden after JS hydration for the animation effect.
*/
export default function ScrollFadeIn({ children, className = "", delay = 0 }: ScrollFadeInProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
const el = ref.current;
if (!el) return;
// Fallback for browsers without IntersectionObserver
if (typeof IntersectionObserver === "undefined") {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.1 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={hasMounted ? {
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(30px)",
transition: `opacity 0.6s ease ${delay}s, transform 0.6s ease ${delay}s`,
} : undefined}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,60 @@
// Pure CSS gradient background — replaces the Three.js/WebGL shader gradient.
// Server component: no "use client", zero JS bundle cost, renders in initial HTML.
// Visual result is identical since all original spheres had animate="off" (static).
export default function ShaderGradientBackground() {
return (
<div
aria-hidden="true"
style={{
position: "fixed",
inset: 0,
zIndex: -1,
overflow: "hidden",
pointerEvents: "none",
}}
>
{/* Upper-left: crimson → pink (was Sphere 1: posX=-2.5, posY=1.5) */}
<div
style={{
position: "absolute",
top: "-10%",
left: "-15%",
width: "55%",
height: "65%",
background:
"radial-gradient(ellipse at 50% 50%, #b01040 0%, #e167c5 40%, transparent 70%)",
filter: "blur(100px)",
opacity: 0.6,
}}
/>
{/* Right-center: pink → crimson (was Sphere 2: posX=2.0, posY=-0.5) */}
<div
style={{
position: "absolute",
top: "25%",
right: "-10%",
width: "50%",
height: "60%",
background:
"radial-gradient(ellipse at 50% 50%, #e167c5 0%, #b01040 40%, transparent 70%)",
filter: "blur(100px)",
opacity: 0.55,
}}
/>
{/* Lower-left: orange → pink (was Sphere 3: posX=-0.5, posY=-2.0) */}
<div
style={{
position: "absolute",
bottom: "-15%",
left: "5%",
width: "50%",
height: "60%",
background:
"radial-gradient(ellipse at 50% 50%, #b04a17 0%, #e167c5 40%, transparent 70%)",
filter: "blur(100px)",
opacity: 0.5,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
theme: "light",
setTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
useEffect(() => {
try {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "dark" || stored === "light") {
setThemeState(stored);
document.documentElement.classList.toggle("dark", stored === "dark");
}
} catch {}
}, []);
const setTheme = (t: Theme) => {
setThemeState(t);
try {
localStorage.setItem("theme", t);
} catch {}
document.documentElement.classList.toggle("dark", t === "dark");
};
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
}
export function useTheme() {
return useContext(ThemeCtx);
}

View File

@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "./ThemeProvider";
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 (
<button
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 hover:scale-105 active:scale-95 transition-transform"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={18} className="text-amber-400" />
) : (
<Moon size={18} className="text-stone-600" />
)}
</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 [isSaving, setIsSaving] = useState(false);
const [isCreating, setIsCreating] = useState(!projectId); const [isCreating, setIsCreating] = useState(!projectId);
const [editLocale, setEditLocale] = useState(initialLocale); 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 [showPreview, setShowPreview] = useState(false);
const [_isTyping, setIsTyping] = useState(false); const [_isTyping, setIsTyping] = useState(false);
const [history, setHistory] = useState<typeof formData[]>([]); const [history, setHistory] = useState<typeof formData[]>([]);
@@ -96,6 +96,7 @@ function EditorPageContent() {
setBaseTexts({ setBaseTexts({
title: foundProject.title || "", title: foundProject.title || "",
description: foundProject.description || "", description: foundProject.description || "",
content: foundProject.content || "",
}); });
const initialData = { const initialData = {
title: foundProject.title || "", title: foundProject.title || "",
@@ -145,19 +146,64 @@ function EditorPageContent() {
}); });
if (!response.ok) return; if (!response.ok) return;
const data = await response.json(); const data = await response.json();
const tr = data.translation as { title?: string; description?: string } | null; const tr = data.translation as { title?: string; description?: string; content?: unknown } | null;
if (tr?.title && tr?.description) { const translatedContent = (() => {
setFormData((prev) => ({ 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, ...prev,
title: tr.title || prev.title, title: tr?.title || prev.title,
description: tr.description || prev.description, description: tr?.description || prev.description,
})); content: translatedContent ?? prev.content,
};
return next;
});
if (translatedContent !== null) {
shouldUpdateContentRef.current = true;
}
} }
} catch { } catch {
// ignore translation load failures // 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 // Check authentication and load project
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@@ -188,6 +234,7 @@ function EditorPageContent() {
live: "", live: "",
image: "", image: "",
}; };
setBaseTexts({ title: "", description: "", content: "" });
setFormData(initialData); setFormData(initialData);
setOriginalFormData(initialData); setOriginalFormData(initialData);
setHistory([initialData]); setHistory([initialData]);
@@ -240,11 +287,12 @@ function EditorPageContent() {
const saveTitle = editLocale === "en" ? formData.title.trim() : (baseTexts?.title || formData.title.trim()); const saveTitle = editLocale === "en" ? formData.title.trim() : (baseTexts?.title || formData.title.trim());
const saveDescription = const saveDescription =
editLocale === "en" ? formData.description.trim() : (baseTexts?.description || formData.description.trim()); editLocale === "en" ? formData.description.trim() : (baseTexts?.description || formData.description.trim());
const saveContent = editLocale === "en" ? formData.content.trim() : (baseTexts?.content || formData.content.trim());
const saveData = { const saveData = {
title: saveTitle, title: saveTitle,
description: saveDescription, description: saveDescription,
content: formData.content.trim(), content: saveContent,
category: formData.category, category: formData.category,
tags: formData.tags, tags: formData.tags,
github: formData.github.trim() || null, github: formData.github.trim() || null,
@@ -302,6 +350,7 @@ function EditorPageContent() {
locale: editLocale, locale: editLocale,
title: formData.title.trim(), title: formData.title.trim(),
description: formData.description.trim(), description: formData.description.trim(),
content: formData.content.trim(),
}), }),
}); });
} catch { } catch {
@@ -309,6 +358,14 @@ function EditorPageContent() {
} }
} }
if (editLocale === "en") {
setBaseTexts({
title: savedProject.title || "",
description: savedProject.description || "",
content: savedProject.content || "",
});
}
// Update project ID if it was a new project // Update project ID if it was a new project
if (!projectId && savedProject.id) { if (!projectId && savedProject.id) {
const newUrl = `/editor?id=${savedProject.id}`; const newUrl = `/editor?id=${savedProject.id}`;
@@ -706,27 +763,40 @@ function EditorPageContent() {
<label className="block text-sm font-medium text-stone-300 mb-2"> <label className="block text-sm font-medium text-stone-300 mb-2">
Language Language
</label> </label>
<div className="flex items-center gap-2">
<div className="custom-select"> <div className="custom-select">
<select <select
value={editLocale} value={editLocale}
onChange={(e) => { onChange={(e) => switchLocale(e.target.value)}
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="en">English (default)</option>
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
</select> </select>
</div> </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" && ( {editLocale !== "en" && (
<p className="text-xs text-stone-400 mt-2"> <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> </p>
)} )}
</div> </div>

View File

@@ -10,12 +10,9 @@ export default function GlobalError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
// Log error details to console if (process.env.NODE_ENV === "development") {
console.error("Global Error:", error); console.error("Global Error:", error);
console.error("Error Name:", error.name); }
console.error("Error Message:", error.message);
console.error("Error Stack:", error.stack);
console.error("Error Digest:", error.digest);
}, [error]); }, [error]);
return ( return (

View File

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

View File

@@ -1,14 +1,24 @@
import "./globals.css"; import "./globals.css";
import { Metadata } from "next"; import { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter, Playfair_Display } from "next/font/google";
import React from "react"; import React from "react";
import ClientProviders from "./components/ClientProviders"; import ClientProviders from "./components/ClientProviders";
import ShaderGradientBackground from "./components/ShaderGradientBackground";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getBaseUrl } from "@/lib/seo"; import { getBaseUrl } from "@/lib/seo";
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
subsets: ["latin"], subsets: ["latin"],
display: "swap",
adjustFontFallback: true,
});
const playfair = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
display: "swap",
adjustFontFallback: true,
}); });
export default async function RootLayout({ export default async function RootLayout({
@@ -19,11 +29,15 @@ export default async function RootLayout({
const cookieStore = await cookies(); const cookieStore = await cookies();
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en"; const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
return ( return (
<html lang={locale}> <html lang={locale} suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
{/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
</head> </head>
<body className={inter.variable} suppressHydrationWarning> <body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" />
<ShaderGradientBackground />
<ClientProviders>{children}</ClientProviders> <ClientProviders>{children}</ClientProviders>
</body> </body>
</html> </html>
@@ -32,11 +46,39 @@ export default async function RootLayout({
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(getBaseUrl()), metadataBase: new URL(getBaseUrl()),
title: "Dennis Konkol | Portfolio", title: {
default: "Dennis Konkol | Portfolio",
template: "%s | Dennis Konkol",
},
description: description:
"Portfolio of Dennis Konkol, a student and software engineer based in Osnabrück, Germany. Passionate about technology, coding, and solving real-world problems.", "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" }], 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: { openGraph: {
title: "Dennis Konkol | Portfolio", title: "Dennis Konkol | Portfolio",
description: description:
@@ -51,6 +93,7 @@ export const metadata: Metadata = {
alt: "Dennis Konkol Portfolio", alt: "Dennis Konkol Portfolio",
}, },
], ],
locale: "en_US",
type: "website", type: "website",
}, },
twitter: { twitter: {
@@ -58,5 +101,12 @@ export const metadata: Metadata = {
title: "Dennis Konkol | Portfolio", title: "Dennis Konkol | Portfolio",
description: "Student & Software Engineer based in Osnabrück, Germany.", description: "Student & Software Engineer based in Osnabrück, Germany.",
images: ["https://dk0.dev/api/og"], images: ["https://dk0.dev/api/og"],
creator: "@denshooter",
},
verification: {
google: process.env.NEXT_PUBLIC_GOOGLE_VERIFICATION,
},
alternates: {
canonical: "https://dk0.dev",
}, },
}; };

View File

@@ -1,21 +1,18 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { motion } from 'framer-motion'; import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import Header from "../components/Header"; import Header from "../components/Header";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient"; import RichTextClient from "../components/RichTextClient";
export default function LegalNotice() { export default function LegalNotice() {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("common"); const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -24,114 +21,111 @@ export default function LegalNotice() {
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`, `/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
); );
const data = await res.json(); const data = await res.json();
// Only use CMS content if it exists for the active locale. if (data?.content?.html && data?.content?.locale === locale) {
if (data?.content?.content && data?.content?.locale === locale) { setCmsHtml(data.content.html as string);
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]); }, [locale]);
return ( return (
<div className="min-h-screen animated-bg"> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<Header /> <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">
<motion.div
initial={{ opacity: 0, y: 30 }} {/* Editorial Header */}
animate={{ opacity: 1, y: 0 }} <div className="animate-fade-in mb-20">
transition={{ duration: 0.8 }}
className="mb-8"
>
<Link <Link
href={`/${locale}`} 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} /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span>{t("backToHome")}</span> <span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
</Link> </Link>
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text"> <h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
{cmsTitle || "Impressum"} Legal<span className="text-liquid-mint">.</span>
</h1> </h1>
</motion.div> </div>
<motion.div {/* Bento Content Grid */}
initial={{ opacity: 0, y: 30 }} <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }} {/* Main Legal Content (Large Box) */}
className="glass-card p-8 rounded-2xl space-y-6" <div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] 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">
> {cmsHtml ? (
{cmsDoc ? ( <div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" /> <RichTextClient html={cmsHtml} />
</div>
) : ( ) : (
<> <div className="space-y-16">
<div className="text-gray-300 leading-relaxed"> <section>
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Inhalte dieser Website</h2> <h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<div className="space-y-2 text-gray-300"> <Scale className="text-liquid-mint" size={28} /> Angaben gemäß § 5 TMG
<p> </h2>
<strong>Name:</strong> Dennis Konkol <div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-4">
</p> <p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
<p> <p>Auf dem Ziegenbrink 2B</p>
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland <p>49082 Osnabrück, Deutschland</p>
</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>
</p>
</div>
</div> </div>
</section>
<div className="text-gray-300"> <section>
<h2 className="text-2xl font-semibold mb-4">Haftung für Links</h2> <h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
<p className="leading-relaxed"> <ShieldCheck className="text-liquid-sky" size={28} /> Haftungsausschluss
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser </h2>
Websites und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der <p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum 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.
Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde
ich derartige Links umgehend entfernen.
</p> </p>
</section>
</div> </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>
{/* 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>
{/* 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>
</div>
</main> </main>
<Footer /> <Footer />
</div> </div>

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { Lock, Loader2 } from 'lucide-react'; import { Lock, Loader2 } from 'lucide-react';
import ModernAdminDashboard from '@/components/ModernAdminDashboard'; import ModernAdminDashboard from '@/components/ModernAdminDashboard';
@@ -259,10 +258,12 @@ const AdminPage = () => {
// Loading state // Loading state
if (authState.isLoading) { if (authState.isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8]"> <div className="min-h-screen flex items-center justify-center bg-stone-50 dark:bg-stone-950">
<div className="text-center"> <div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-stone-600" /> <div className="font-mono text-sm font-black tracking-tighter text-stone-900 dark:text-stone-50">
<p className="text-stone-500">Loading...</p> dk<span className="text-red-500">0</span>.dev
</div>
<Loader2 className="w-5 h-5 animate-spin mx-auto text-emerald-500" />
</div> </div>
</div> </div>
); );
@@ -271,13 +272,23 @@ const AdminPage = () => {
// Lockout state // Lockout state
if (authState.isLocked) { if (authState.isLocked) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#fdfcf8]"> <div className="min-h-screen flex items-center justify-center bg-stone-50 dark:bg-stone-950 px-6">
<div className="text-center"> <div className="w-full max-w-sm">
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-6"> <div className="bg-white dark:bg-stone-900 rounded-[2.5rem] border border-stone-200 dark:border-stone-800 overflow-hidden shadow-2xl">
<Lock className="w-8 h-8 text-red-500" /> <div className="h-0.5 bg-gradient-to-r from-red-500 via-orange-400 to-red-400" />
<div className="p-10 text-center">
<div className="w-14 h-14 bg-red-50 dark:bg-red-950/30 rounded-[1.25rem] flex items-center justify-center mx-auto mb-6 border border-red-200 dark:border-red-900">
<Lock className="w-6 h-6 text-red-500" />
</div> </div>
<h2 className="text-2xl font-bold text-stone-900 mb-2">Account Locked</h2> <p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-4">
<p className="text-stone-500">Too many failed attempts. Please try again in 15 minutes.</p> dk<span className="text-red-500">0</span>.dev · admin
</p>
<h1 className="text-2xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 mb-2">
Account Locked
</h1>
<p className="text-stone-500 dark:text-stone-400 text-sm leading-relaxed mb-8">
Too many failed attempts. Please try again in 15 minutes.
</p>
<button <button
onClick={() => { onClick={() => {
try { try {
@@ -287,82 +298,98 @@ const AdminPage = () => {
} }
window.location.reload(); 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="px-8 py-3 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all"
> >
Try Again Try Again
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
); );
} }
// Login form // Login form
if (authState.showLogin || !authState.isAuthenticated) { if (authState.showLogin || !authState.isAuthenticated) {
return ( 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-stone-50 dark:bg-stone-950 px-6">
{/* Liquid ambient blobs */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-[5%] left-[5%] w-[50vw] h-[50vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
<div className="absolute bottom-[5%] right-[5%] w-[40vw] h-[40vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
</div>
<motion.div <div className="relative z-10 w-full max-w-sm animate-[fadeIn_0.4s_ease-out]">
initial={{ opacity: 0, scale: 0.95 }} <div className="bg-white dark:bg-stone-900 rounded-[2.5rem] border border-stone-200 dark:border-stone-800 overflow-hidden shadow-2xl">
animate={{ opacity: 1, scale: 1 }} <div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
className="w-full max-w-md p-6"
> <div className="p-10">
<div className="bg-white/80 backdrop-blur-xl rounded-3xl p-8 border border-stone-200 shadow-2xl relative z-10">
<div className="text-center mb-8"> <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"> <p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-5">
<Lock className="w-6 h-6 text-stone-600" /> dk<span className="text-red-500">0</span>.dev · admin
</p>
<div className="w-14 h-14 bg-stone-100 dark:bg-stone-800 rounded-[1.25rem] flex items-center justify-center mx-auto mb-5 border border-stone-200 dark:border-stone-700">
<Lock className="w-6 h-6 text-stone-700 dark:text-stone-300" />
</div> </div>
<h1 className="text-2xl font-bold text-stone-900 mb-2 tracking-tight">Admin Access</h1> <h1 className="text-3xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 mb-1">
<p className="text-stone-500">Enter your password to continue</p> Admin Access
</h1>
<p className="text-stone-500 dark:text-stone-400 text-sm">
Enter your password to continue
</p>
</div> </div>
<form onSubmit={handleLogin} className="space-y-5"> <form onSubmit={handleLogin} className="space-y-4">
<div> <div>
<div className="relative"> <div className="relative">
<input <input
type={authState.showPassword ? 'text' : 'password'} type={authState.showPassword ? 'text' : 'password'}
value={authState.password} value={authState.password}
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))} onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
placeholder="Enter password" placeholder="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-5 py-4 bg-stone-50 dark:bg-stone-950/50 border border-stone-200 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:border-transparent transition-all"
disabled={authState.isLoading} disabled={authState.isLoading}
/> />
<button <button
type="button" type="button"
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))} 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-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 p-1 transition-colors"
> >
{authState.showPassword ? '👁️' : '👁️‍🗨️'} {authState.showPassword ? '👁️' : '👁️‍🗨️'}
</button> </button>
</div> </div>
{authState.error && ( {authState.error && (
<motion.p <p className="mt-2 text-red-500 text-sm font-medium flex items-center gap-2">
initial={{ opacity: 0, y: -5 }} <span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-red-500 text-sm font-medium flex items-center"
>
<span className="w-1.5 h-1.5 bg-red-500 rounded-full mr-2" />
{authState.error} {authState.error}
</motion.p> </p>
)} )}
</div> </div>
<button <button
type="submit" type="submit"
disabled={authState.isLoading || !authState.password} 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-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 py-4 rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all flex items-center justify-center gap-2"
> >
{authState.isLoading ? ( {authState.isLoading ? (
<div className="flex items-center justify-center space-x-2"> <>
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
<span className="text-stone-50">Authenticating...</span> Authenticating
</div> </>
) : ( ) : (
<span className="text-stone-50">Sign In</span> 'Sign In'
)} )}
</button> </button>
</form> </form>
</div> </div>
</motion.div> </div>
{authState.attempts > 0 && (
<p className="text-center text-xs text-stone-400 mt-4">
{5 - authState.attempts} attempt{5 - authState.attempts !== 1 ? 's' : ''} remaining
</p>
)}
</div>
</div> </div>
); );
} }

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