Compare commits

...

60 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
83 changed files with 2791 additions and 6043 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

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

View File

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

View File

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

View File

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

9
.gitignore vendored
View File

@@ -1,5 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Local tooling
.claude/settings.local.json
.claude/CLAUDE.local.md
._*
# dependencies
/node_modules
/.pnp
@@ -33,10 +38,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
# Sentry
.sentryclirc
sentry.properties
# vercel
.vercel

183
CLAUDE.md
View File

@@ -1,23 +1,24 @@
# CLAUDE.md - Portfolio Project Guide
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "liquid" design system with soft gradient colors and glassmorphism effects.
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "Liquid Editorial Bento" design system with soft gradient colors and glassmorphism effects.
## Tech Stack
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
- **Framework**: Next.js 15 (App Router), TypeScript 5.9, React 19
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
- **Animations**: Framer Motion 12
- **3D**: Three.js + React Three Fiber (shader gradient background)
- **3D**: Three.js + React Three Fiber + `@shadergradient/react` (shader gradient background)
- **Database**: PostgreSQL via Prisma ORM
- **Cache**: Redis (optional)
- **CMS**: Directus (self-hosted, REST/GraphQL, optional)
- **CMS**: Directus (self-hosted, GraphQL only, optional)
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
- **i18n**: next-intl (EN + DE), message files in `messages/`
- **Monitoring**: Sentry
- **Deployment**: Docker + Nginx, CI via Gitea Actions
- **Deployment**: Docker + Nginx, CI via Gitea Actions (`output: "standalone"`)
## Commands
@@ -26,76 +27,54 @@ npm run dev # Full dev environment (Docker + Next.js)
npm run dev:simple # Next.js only (no Docker)
npm run dev:next # Plain Next.js dev server
npm run build # Production build
npm run lint # ESLint
npm run test # Jest unit tests
npm run lint # ESLint (0 errors required, warnings OK)
npm run lint:fix # Auto-fix lint issues
npm run test # All Jest unit tests
npx jest path/to/test.tsx # Run a single test file
npm run test:watch # Watch mode
npm run test:e2e # Playwright E2E tests
npm run db:generate # Regenerate Prisma client after schema changes
```
## Project Structure
## Architecture
```
app/
[locale]/ # i18n routes (en, de)
page.tsx # Homepage (hero, about, projects, contact)
projects/ # Project listing + detail pages
api/ # API routes
book-reviews/ # Book reviews from Directus CMS
content/ # CMS content pages
hobbies/ # Hobbies from Directus
n8n/ # n8n webhook proxies
hardcover/ # Currently reading (Hardcover API via n8n)
status/ # Activity status (coding, music, gaming)
chat/ # AI chatbot
generate-image/ # AI image generation
projects/ # Projects API (PostgreSQL + Directus fallback)
tech-stack/ # Tech stack from Directus
components/ # React components
About.tsx # About section (tech stack, hobbies, books)
CurrentlyReading.tsx # Currently reading widget (n8n/Hardcover)
ReadBooks.tsx # Read books with ratings (Directus CMS)
Projects.tsx # Featured projects section
Hero.tsx # Hero section
Contact.tsx # Contact form
lib/
directus.ts # Directus GraphQL client (no SDK)
auth.ts # Auth utilities + rate limiting
prisma/
schema.prisma # Database schema
messages/
en.json # English translations
de.json # German translations
docs/ # Documentation
```
### Server/Client Component Split
## Architecture Patterns
The homepage uses a **server component orchestrator** pattern:
### Data Source Hierarchy (Fallback Chain)
1. Directus CMS (if configured via `DIRECTUS_STATIC_TOKEN`)
2. PostgreSQL (for projects, analytics)
3. JSON files (`messages/*.json`)
4. Hardcoded defaults
5. Display key itself as last resort
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps below-fold sections in `ScrollFadeIn`
- `app/components/Hero.tsx`**server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`; each wraps its component in a scoped `NextIntlClientProvider` with only the needed translation namespace
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable.
### SSR Animation Safety
**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if JS hydration fails or is slow.
Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) instead: renders no inline style during SSR, applies opacity+transform only after `hasMounted` check via IntersectionObserver + CSS transitions.
`AnimatePresence` is fine for modals/overlays that only render after user interaction.
### Data Source Fallback Chain
Every data fetch degrades gracefully — the site never crashes:
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured) → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
### CMS Integration (Directus)
- REST/GraphQL calls via `lib/directus.ts` (no Directus SDK)
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
- Returns `null` on failure, never throws
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
- Translations use Directus native translation system (M2O to `languages`)
- Locale mapping: `en` -> `en-US`, `de` -> `de-DE`
- Translations use Directus native M2O system; locale mapping: `en``en-US`, `de``de-DE`
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and include a `source` field in the response (`"directus"` | `"fallback"` | `"error"`)
### n8n Integration
- Webhook base URL: `N8N_WEBHOOK_URL` env var
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- All n8n endpoints have rate limiting and timeout protection (10s)
- Hardcover data cached for 5 minutes
### Component Patterns
- Client components with `"use client"` for interactive/data-fetching parts
- `useEffect` for data loading on mount
- `useTranslations` from next-intl for i18n
- Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
- Gradient cards with `liquid-*` color tokens and `backdrop-blur-sm`
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- All endpoints have rate limiting and 10s timeout
- Hardcover reading data cached 5 minutes
## Design System
@@ -103,54 +82,54 @@ Custom Tailwind colors prefixed with `liquid-`:
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
Cards use gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`) with `border-2` and `rounded-xl`.
Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`.
Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<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
# Required for CMS
DIRECTUS_URL=https://cms.dk0.dev
DIRECTUS_STATIC_TOKEN=...
# Required for n8n features
N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=...
N8N_API_KEY=...
# Database
DATABASE_URL=postgresql://...
# Optional
REDIS_URL=redis://...
SENTRY_DSN=...
REDIS_URL=redis://... # optional
```
## Conventions
## Adding a CMS-managed Section
- Language: Code in English, user-facing text via i18n (EN + DE)
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
- Components: PascalCase files in `app/components/`
- API routes: kebab-case directories in `app/api/`
- CMS data always has a static fallback - never rely solely on Directus
- Error logging: Only in `development` mode (`process.env.NODE_ENV === "development"`)
- No emojis in code unless explicitly requested
## Common Tasks
### Adding a new CMS-managed section
1. Define the GraphQL query + types in `lib/directus.ts`
2. Create an API route in `app/api/<name>/route.ts`
3. Create a component in `app/components/<Name>.tsx`
4. Add i18n keys to `messages/en.json` and `messages/de.json`
5. Integrate into the parent component (usually `About.tsx`)
### Adding i18n strings
1. Add keys to `messages/en.json` and `messages/de.json`
2. Access via `useTranslations("key.path")` in client components
3. Or `getTranslations("key.path")` in server components
### Working with Directus collections
- All queries go through `directusRequest()` in `lib/directus.ts`
- Uses GraphQL endpoint (`/graphql`)
- 2-second timeout, graceful null fallback
- Translations filtered by `languages_code.code` matching Directus locale
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>`

View File

@@ -31,10 +31,10 @@ RUN npx prisma generate
# Copy source code (this invalidates cache when code changes)
COPY . .
# Build the application
# Build the application (mount cache for faster rebuilds)
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
RUN --mount=type=cache,target=/app/.next/cache npm run build
# Verify standalone output was created and show structure for debugging
RUN if [ ! -d .next/standalone ]; then \

View File

@@ -58,7 +58,7 @@ describe('ActivityFeed NaN Handling', () => {
});
it('should convert gaming.name to string safely', () => {
const validName = String('Test Game' || '');
const validName = String('Test Game');
expect(validName).toBe('Test Game');
expect(typeof validName).toBe('string');

View File

@@ -11,6 +11,7 @@ jest.mock("next-intl", () => ({
// Mock next/image
jest.mock("next/image", () => ({
__esModule: true,
// eslint-disable-next-line @next/next/no-img-element
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
}));

View File

@@ -25,10 +25,10 @@ describe('Header', () => {
render(<Header />);
expect(screen.getByText('dk')).toBeInTheDocument();
// Check for navigation links
expect(screen.getByText('Home')).toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument();
expect(screen.getByText('Projects')).toBeInTheDocument();
expect(screen.getByText('Contact')).toBeInTheDocument();
// Check for navigation links (appear in both desktop and mobile menus)
expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
expect(screen.getAllByText('About').length).toBeGreaterThan(0);
expect(screen.getAllByText('Projects').length).toBeGreaterThan(0);
expect(screen.getAllByText('Contact').length).toBeGreaterThan(0);
});
});

View File

@@ -1,16 +1,19 @@
import { render, screen } from '@testing-library/react';
import Hero from '@/app/components/Hero';
// Mock next-intl
jest.mock('next-intl', () => ({
useLocale: () => 'en',
useTranslations: () => (key: string) => {
// Mock next-intl/server
jest.mock('next-intl/server', () => ({
getTranslations: () => Promise.resolve((key: string) => {
const messages: Record<string, string> = {
badge: 'Student & Self-Hoster',
line1: 'Building',
line2: 'Stuff.',
description: 'Dennis is a student and passionate self-hoster.',
ctaWork: 'View My Work'
ctaWork: 'View My Work',
ctaContact: 'Get in touch',
};
return messages[key] || key;
},
}),
}));
// Mock next/image
@@ -25,6 +28,7 @@ interface ImageProps {
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={alt}
@@ -36,8 +40,9 @@ jest.mock('next/image', () => ({
}));
describe('Hero', () => {
it('renders the hero section correctly', () => {
render(<Hero />);
it('renders the hero section correctly', async () => {
const HeroResolved = await Hero({ locale: 'en' });
render(HeroResolved);
// Check for the main headlines (defaults in Hero.tsx)
expect(screen.getByText('Building')).toBeInTheDocument();

View File

@@ -1,12 +1,13 @@
import { render, screen } from "@testing-library/react";
import { ThemeToggle } from "@/app/components/ThemeToggle";
// Mock next-themes
jest.mock("next-themes", () => ({
// Mock custom ThemeProvider
jest.mock("@/app/components/ThemeProvider", () => ({
useTheme: () => ({
theme: "light",
setTheme: jest.fn(),
}),
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
describe("ThemeToggle Component", () => {

View File

@@ -41,7 +41,7 @@ export default function HomePage() {
{/* Spacer to prevent navbar overlap */}
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<Hero />
<Hero locale="en" />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">

View File

@@ -1,14 +1,14 @@
import Header from "../components/Header.server";
import Hero from "../components/Hero";
import ScrollFadeIn from "../components/ScrollFadeIn";
import Script from "next/script";
import {
getHeroTranslations,
getAboutTranslations,
getProjectsTranslations,
getContactTranslations,
getFooterTranslations,
} from "@/lib/translations-loader";
import {
HeroClient,
AboutClient,
ProjectsClient,
ContactClient,
@@ -20,9 +20,8 @@ interface HomePageServerProps {
}
export default async function HomePageServer({ locale }: HomePageServerProps) {
// Parallel laden aller Translations
const [heroT, aboutT, projectsT, contactT, footerT] = await Promise.all([
getHeroTranslations(locale),
// Parallel laden aller Translations (hero translations handled by Hero server component)
const [aboutT, projectsT, contactT, footerT] = await Promise.all([
getAboutTranslations(locale),
getProjectsTranslations(locale),
getContactTranslations(locale),
@@ -57,7 +56,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
{/* Spacer to prevent navbar overlap */}
<div className="h-24 md:h-32" aria-hidden="true"></div>
<main className="relative">
<HeroClient locale={locale} translations={heroT} />
<Hero locale={locale} />
{/* Wavy Separator 1 - Hero to About */}
<div className="relative h-24 overflow-hidden">
@@ -80,7 +79,9 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
</svg>
</div>
<AboutClient locale={locale} translations={aboutT} />
<ScrollFadeIn>
<AboutClient locale={locale} translations={aboutT} />
</ScrollFadeIn>
{/* Wavy Separator 2 - About to Projects */}
<div className="relative h-24 overflow-hidden">
@@ -103,7 +104,9 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
</svg>
</div>
<ProjectsClient locale={locale} translations={projectsT} />
<ScrollFadeIn>
<ProjectsClient locale={locale} translations={projectsT} />
</ScrollFadeIn>
{/* Wavy Separator 3 - Projects to Contact */}
<div className="relative h-24 overflow-hidden">
@@ -126,9 +129,13 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
</svg>
</div>
<ContactClient locale={locale} translations={contactT} />
<ScrollFadeIn>
<ContactClient locale={locale} translations={contactT} />
</ScrollFadeIn>
</main>
<FooterClient locale={locale} translations={footerT} />
<ScrollFadeIn>
<FooterClient locale={locale} translations={footerT} />
</ScrollFadeIn>
</div>
);
}

View File

@@ -3,7 +3,9 @@ import { getBookReviews } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/**
* GET /api/book-reviews
@@ -31,25 +33,23 @@ export async function GET(request: NextRequest) {
}
if (reviews && reviews.length > 0) {
return NextResponse.json({
bookReviews: reviews,
source: 'directus'
});
return NextResponse.json(
{ bookReviews: reviews, source: 'directus' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
return NextResponse.json({
bookReviews: null,
source: 'fallback'
});
return NextResponse.json(
{ bookReviews: null, source: 'fallback' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
console.error('Error loading book reviews:', error);
if (process.env.NODE_ENV === 'development') {
console.error('Error loading book reviews:', error);
}
return NextResponse.json(
{
bookReviews: null,
error: 'Failed to load book reviews',
source: 'error'
},
{ bookReviews: null, error: 'Failed to load book reviews', source: 'error' },
{ status: 500 }
);
}

View File

@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getContentByKey } from "@/lib/content";
import { getContentPage } from "@/lib/directus";
import { richTextToSafeHtml } from "@/lib/richtext";
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@@ -15,21 +18,35 @@ export async function GET(request: NextRequest) {
// 1) Try Directus first
const directusPage = await getContentPage(key, locale);
if (directusPage) {
return NextResponse.json({
content: {
title: directusPage.title,
slug: directusPage.slug,
locale: directusPage.locale || locale,
content: directusPage.content,
// 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",
},
source: "directus",
});
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
// 2) Fallback: PostgreSQL
const translation = await getContentByKey({ key, locale });
if (!translation) return NextResponse.json({ content: null });
return NextResponse.json({ content: translation, source: "postgresql" });
if (!translation) {
return NextResponse.json(
{ content: null },
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
return NextResponse.json(
{ content: translation, source: "postgresql" },
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
// If DB isn't migrated/available, fail soft so the UI can fall back to next-intl strings.
if (process.env.NODE_ENV === "development") {

View File

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

View File

@@ -5,12 +5,8 @@ import Mail from "nodemailer/lib/mailer";
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
import { prisma } from "@/lib/prisma";
// Sanitize input to prevent XSS
function sanitizeInput(input: string, maxLength: number = 10000): string {
return input
.slice(0, maxLength)
.replace(/[<>]/g, '') // Remove potential HTML tags
.trim();
return input.slice(0, maxLength).replace(/[<>]/g, '').trim();
}
function escapeHtml(input: string): string {
@@ -22,19 +18,126 @@ function escapeHtml(input: string): string {
.replace(/'/g, "&#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) {
try {
// Rate limiting (defensive: headers may be undefined in tests)
const ip = request.headers?.get?.('x-forwarded-for') ?? request.headers?.get?.('x-real-ip') ?? 'unknown';
if (!checkRateLimit(ip, 5, 60000)) { // 5 emails per minute per IP
if (!checkRateLimit(ip, 5, 60000)) {
return NextResponse.json(
{ error: 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.' },
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 5, 60000)
}
headers: { 'Content-Type': 'application/json', ...getRateLimitHeaders(ip, 5, 60000) },
}
);
}
@@ -46,48 +149,26 @@ export async function POST(request: NextRequest) {
message: string;
};
// Sanitize and validate input
const email = sanitizeInput(body.email || '', 255);
const name = sanitizeInput(body.name || '', 100);
const subject = sanitizeInput(body.subject || '', 200);
const message = sanitizeInput(body.message || '', 5000);
// Email request received
// Validate input
if (!email || !name || !subject || !message) {
console.error('❌ Validation failed: Missing required fields');
return NextResponse.json(
{ error: "Alle Felder sind erforderlich" },
{ status: 400 },
);
return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
console.error('❌ Validation failed: Invalid email format');
return NextResponse.json(
{ error: "Ungültige E-Mail-Adresse" },
{ status: 400 },
);
return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
}
// Validate message length
if (message.length < 10) {
console.error('❌ Validation failed: Message too short');
return NextResponse.json(
{ error: "Nachricht muss mindestens 10 Zeichen lang sein" },
{ status: 400 },
);
return NextResponse.json({ error: "Nachricht muss mindestens 10 Zeichen lang sein" }, { status: 400 });
}
// Validate field lengths
if (name.length > 100 || subject.length > 200 || message.length > 5000) {
return NextResponse.json(
{ error: "Eingabe zu lang" },
{ status: 400 },
);
return NextResponse.json({ error: "Eingabe zu lang" }, { status: 400 });
}
const user = process.env.MY_EMAIL ?? "";
@@ -95,265 +176,98 @@ export async function POST(request: NextRequest) {
if (!user || !pass) {
console.error("❌ Missing email/password environment variables");
return NextResponse.json(
{ error: "E-Mail-Server nicht konfiguriert" },
{ status: 500 },
);
return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
}
const transportOptions: SMTPTransport.Options = {
host: "mail.dk0.dev",
port: 587,
secure: false, // Port 587 uses STARTTLS, not SSL/TLS
secure: false,
requireTLS: true,
auth: {
type: "login",
user,
pass,
},
// Increased timeout settings for better reliability
connectionTimeout: 30000, // 30 seconds
greetingTimeout: 30000, // 30 seconds
socketTimeout: 60000, // 60 seconds
// TLS hardening (allow insecure/self-signed only when explicitly enabled)
auth: { type: "login", user, pass },
connectionTimeout: 30000,
greetingTimeout: 30000,
socketTimeout: 60000,
tls:
process.env.SMTP_ALLOW_INSECURE_TLS === "true" ||
process.env.SMTP_ALLOW_SELF_SIGNED === "true"
? { rejectUnauthorized: false }
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true"
? { rejectUnauthorized: false }
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
};
// Creating transport with configured options
const transport = nodemailer.createTransport(transportOptions);
// Verify transport configuration with retry logic
let verificationAttempts = 0;
const maxVerificationAttempts = 3;
let verificationSuccess = false;
while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
while (verificationAttempts < 3) {
try {
verificationAttempts++;
await transport.verify();
verificationSuccess = true;
break;
} catch (verifyError) {
if (process.env.NODE_ENV === 'development') {
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
}
if (verificationAttempts >= maxVerificationAttempts) {
if (process.env.NODE_ENV === 'development') {
console.error('All SMTP verification attempts failed');
}
return NextResponse.json(
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
{ status: 500 },
);
if (verificationAttempts >= 3) {
return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
const brandUrl = "https://dk0.dev";
const sentAt = new Date().toLocaleString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
const safeName = escapeHtml(name);
const safeEmail = escapeHtml(email);
const safeSubject = escapeHtml(subject);
const safeMessageHtml = escapeHtml(message).replace(/\n/g, "<br>");
const initial = (name.trim()[0] || "?").toUpperCase();
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
const messageHtml = escapeHtml(message).replace(/\n/g, "<br>");
const mailOptions: Mail.Options = {
from: `"Portfolio Contact" <${user}>`,
to: "contact@dk0.dev", // Send to your contact email
to: "contact@dk0.dev",
replyTo: email,
subject: `Portfolio Kontakt: ${subject}`,
html: `
<!DOCTYPE html>
<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.
`,
subject: `📬 Neue Anfrage: ${subject}`,
html: buildNotificationEmail({ name, email, subject, messageHtml, initial, replyHref, sentAt }),
text: `Neue Kontaktanfrage\n\nVon: ${name} (${email})\nBetreff: ${subject}\n\n${message}\n\n---\nEingegangen: ${sentAt}`,
};
// Sending email
// Email sending with retry logic
let sendAttempts = 0;
const maxSendAttempts = 3;
let sendSuccess = false;
let result = '';
while (sendAttempts < maxSendAttempts && !sendSuccess) {
while (sendAttempts < 3) {
try {
sendAttempts++;
// Email send attempt
const sendMailPromise = () =>
new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, function (err, info) {
if (!err) {
// Email sent successfully
resolve(info.response);
} else {
if (process.env.NODE_ENV === 'development') {
console.error("Error sending email:", err);
}
reject(err.message);
}
});
result = await new Promise<string>((resolve, reject) => {
transport.sendMail(mailOptions, (err, info) => {
if (!err) resolve(info.response);
else {
if (process.env.NODE_ENV === 'development') console.error("Error sending email:", err);
reject(err.message);
}
});
result = await sendMailPromise();
sendSuccess = true;
// Email process completed successfully
});
break;
} catch (sendError) {
if (process.env.NODE_ENV === 'development') {
console.error(`Email send attempt ${sendAttempts} failed:`, sendError);
if (sendAttempts >= 3) {
throw new Error(`Failed to send email after 3 attempts: ${sendError}`);
}
if (sendAttempts >= maxSendAttempts) {
if (process.env.NODE_ENV === 'development') {
console.error('All email send attempts failed');
}
throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
// Save contact to database
// Save to DB
try {
await prisma.contact.create({
data: {
name,
email,
subject,
message,
responded: false
}
});
// Contact saved to database
await prisma.contact.create({ data: { name, email, subject, message, responded: false } });
} catch (dbError) {
if (process.env.NODE_ENV === 'development') {
console.error('Error saving contact to database:', dbError);
}
// Don't fail the email send if DB save fails
if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError);
}
return NextResponse.json({
message: "E-Mail erfolgreich gesendet",
messageId: result
});
return NextResponse.json({ message: "E-Mail erfolgreich gesendet", messageId: result });
} catch (err) {
console.error("❌ Unexpected error in email API:", err);
return NextResponse.json({
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 });
}
}

View File

@@ -3,7 +3,9 @@ import { getHobbies } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/**
* GET /api/hobbies
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
const hobbies = await getHobbies(locale);
if (hobbies && hobbies.length > 0) {
return NextResponse.json({
hobbies,
source: 'directus'
});
return NextResponse.json(
{ hobbies, source: 'directus' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
// Fallback: return empty (component will use hardcoded fallback)
return NextResponse.json({
hobbies: null,
source: 'fallback'
});
return NextResponse.json(
{ hobbies: null, source: 'fallback' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
console.error('Error loading hobbies:', error);
if (process.env.NODE_ENV === 'development') {
console.error('Error loading hobbies:', error);
}
return NextResponse.json(
{
hobbies: null,
error: 'Failed to load hobbies',
source: 'error'
},
{ hobbies: null, error: 'Failed to load hobbies', source: 'error' },
{ status: 500 }
);
}

View File

@@ -1,13 +1,18 @@
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 });
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

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

@@ -1,11 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
// A faulty API route to test Sentry's error monitoring
export function GET() {
const testError = new Error("Sentry Example API Route Error");
Sentry.captureException(testError);
return NextResponse.json({ error: "This is a test error from the API route" }, { status: 500 });
}

View File

@@ -1,6 +1,8 @@
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);
@@ -9,9 +11,10 @@ export async function GET(request: NextRequest) {
const snippets = await getSnippets(limit, featured);
return NextResponse.json({
snippets: snippets || []
});
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

@@ -3,7 +3,9 @@ import { getTechStack } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/**
* GET /api/tech-stack
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
const techStack = await getTechStack(locale);
if (techStack && techStack.length > 0) {
return NextResponse.json({
techStack,
source: 'directus'
});
return NextResponse.json(
{ techStack, source: 'directus' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
}
// Fallback: return empty (component will use hardcoded fallback)
return NextResponse.json({
techStack: null,
source: 'fallback'
});
return NextResponse.json(
{ techStack: null, source: 'fallback' },
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
);
} catch (error) {
console.error('Error loading tech stack:', error);
if (process.env.NODE_ENV === 'development') {
console.error('Error loading tech stack:', error);
}
return NextResponse.json(
{
techStack: null,
error: 'Failed to load tech stack',
source: 'error'
},
{ techStack: null, error: 'Failed to load tech stack', source: 'error' },
{ status: 500 }
);
}

View File

@@ -3,8 +3,8 @@
import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading";
import ReadBooks from "./ReadBooks";
import { motion, AnimatePresence } from "framer-motion";
@@ -22,29 +22,28 @@ const iconMap: Record<string, LucideIcon> = {
const About = () => {
const locale = useLocale();
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 [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [cmsRes, techRes, hobbiesRes, msgRes, booksRes, snippetsRes] = await Promise.all([
const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
fetch(`/api/content/page?key=home-about&locale=${locale}`),
fetch(`/api/tech-stack?locale=${locale}`),
fetch(`/api/hobbies?locale=${locale}`),
fetch(`/api/messages?locale=${locale}`),
fetch(`/api/book-reviews?locale=${locale}`),
fetch(`/api/snippets?limit=3&featured=true`)
]);
const cmsData = await cmsRes.json();
if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent);
if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
const techData = await techRes.json();
if (techData?.techStack) setTechStack(techData.techStack);
@@ -57,9 +56,6 @@ const About = () => {
const snippetsData = await snippetsRes.json();
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
await booksRes.json();
// Books data is available but we don't need to track count anymore
} catch (error) {
console.error("About data fetch failed:", error);
} finally {
@@ -83,14 +79,11 @@ const About = () => {
{/* 1. Large Bio Text */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="space-y-5 sm:space-y-6 md:space-y-8">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
{t("title")}<span className="text-liquid-mint">.</span>
{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 ? (
@@ -99,15 +92,15 @@ const About = () => {
<Skeleton className="h-6 w-[95%]" />
<Skeleton className="h-6 w-[90%]" />
</div>
) : cmsDoc ? (
<RichTextClient doc={cmsDoc} />
) : cmsHtml ? (
<RichTextClient html={cmsHtml} />
) : (
<p>{t("p1")} {t("p2")}</p>
)}
</div>
<div className="pt-4 sm:pt-6 md:pt-8">
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-1 sm:mb-2">{t("funFactTitle")}</p>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
</div>
</div>
@@ -116,9 +109,6 @@ const About = () => {
{/* 2. Activity / Status Box */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
>
@@ -133,9 +123,6 @@ const About = () => {
{/* 3. AI Chat Box */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
>
@@ -150,9 +137,6 @@ const About = () => {
{/* 4. Tech Stack */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
@@ -171,7 +155,7 @@ const About = () => {
) : (
techStack.map((cat) => (
<div key={cat.id} className="space-y-6">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">{cat.name}</h4>
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
<div className="flex flex-wrap gap-2">
{cat.items?.map((item: 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">
@@ -189,9 +173,6 @@ const About = () => {
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
{/* Library - Larger Span */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.4 }}
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
>
@@ -214,9 +195,6 @@ const About = () => {
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
{/* My Gear (Uses) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }}
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
>
@@ -247,9 +225,6 @@ const About = () => {
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.6 }}
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
>
@@ -267,16 +242,16 @@ const About = () => {
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-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
<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-400 italic">No snippets yet.</p>
<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-400 hover:text-stone-900 dark:hover:text-white transition-colors">
<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>
@@ -285,9 +260,6 @@ const About = () => {
{/* 6. Hobbies */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }}
className="md:col-span-12"
>
@@ -375,7 +347,7 @@ const About = () => {
<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"
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>

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
interface CustomActivity {
[key: string]: unknown;
@@ -109,7 +110,7 @@ export default function ActivityFeed({
clearInterval(statusInterval);
clearInterval(quoteInterval);
};
}, [onActivityChange]);
}, [onActivityChange, allQuotes.length]);
if (loading) {
return <div className="animate-pulse space-y-4">
@@ -133,7 +134,7 @@ export default function ActivityFeed({
transition={{ duration: 0.5 }}
className="space-y-4"
>
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-700 dark:text-stone-300 italic">
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-300 italic">
&ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
@@ -172,7 +173,7 @@ export default function ActivityFeed({
<div className="flex gap-4">
{data.gaming.image && (
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative">
<img src={data.gaming.image} alt={data.gaming.name} className="w-full h-full object-cover" />
<Image src={data.gaming.image} alt={data.gaming.name} fill className="object-cover" sizes="48px" unoptimized />
</div>
)}
<div className="min-w-0 flex flex-col justify-center">
@@ -215,10 +216,12 @@ export default function ActivityFeed({
</div>
<div className="flex gap-4 relative z-10">
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-500">
<img
<Image
src={data.music.albumArt}
alt="Album Art"
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
fill
className="object-cover transition-transform duration-700 group-hover:scale-110"
sizes="64px"
/>
</div>
<div className="min-w-0 flex flex-col justify-center">

View File

@@ -105,7 +105,7 @@ export default function BentoChat() {
placeholder="Ask me..."
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
/>
<button onClick={handleSend} className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
<Send size={18} />
</button>
</div>

View File

@@ -7,12 +7,11 @@ import { ToastProvider } from "@/components/Toast";
import ErrorBoundary from "@/components/ErrorBoundary";
import { ConsentProvider } from "./ConsentProvider";
import { ThemeProvider } from "./ThemeProvider";
import { motion, AnimatePresence } from "framer-motion";
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
ssr: false,
loading: () => null,
});
const BackgroundBlobs = dynamic(
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
{ ssr: false, loading: () => null }
);
export default function ClientProviders({
children,
@@ -20,66 +19,19 @@ export default function ClientProviders({
children: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
const [is404Page, setIs404Page] = useState(false);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
// Check if we're on a 404 page by looking for the data attribute or pathname
const check404 = () => {
try {
if (typeof window !== "undefined" && typeof document !== "undefined") {
const has404Component = document.querySelector('[data-404-page]');
const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
setIs404Page(!!has404Component || is404Path);
}
} catch (error) {
// Silently fail - 404 detection is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error checking 404 status:', error);
}
}
};
// Check immediately and after a short delay
try {
check404();
const timeout = setTimeout(check404, 100);
const interval = setInterval(check404, 500);
return () => {
try {
clearTimeout(timeout);
clearInterval(interval);
} catch {
// Silently fail during cleanup
}
};
} catch (error) {
// If setup fails, just return empty cleanup
if (process.env.NODE_ENV === 'development') {
console.warn('Error setting up 404 check:', error);
}
return () => {};
}
}, [pathname]);
// Wrap in multiple error boundaries to isolate failures
return (
<ErrorBoundary>
<ErrorBoundary>
<ConsentProvider>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<GatedProviders mounted={mounted} is404Page={is404Page}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={pathname}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
<ThemeProvider>
<GatedProviders mounted={mounted}>
{children}
</GatedProviders>
</ThemeProvider>
</ConsentProvider>
@@ -94,12 +46,25 @@ function GatedProviders({
}: {
children: React.ReactNode;
mounted: boolean;
is404Page: boolean;
}) {
// Defer animated background blobs until after LCP
const [deferredReady, setDeferredReady] = useState(false);
useEffect(() => {
if (!mounted) return;
let id: ReturnType<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]);
return (
<ErrorBoundary>
<ToastProvider>
{mounted && <BackgroundBlobs />}
{deferredReady && <BackgroundBlobs />}
<div className="relative z-10">{children}</div>
</ToastProvider>
</ErrorBoundary>

View File

@@ -6,13 +6,15 @@
*/
import { NextIntlClientProvider } from 'next-intl';
import Hero from './Hero';
import About from './About';
import Projects from './Projects';
import Contact from './Contact';
import Footer from './Footer';
import dynamic from 'next/dynamic';
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
// SSR stays on (default) so content is in the initial HTML for SEO.
const About = dynamic(() => import('./About'));
const Projects = dynamic(() => import('./Projects'));
const Contact = dynamic(() => import('./Contact'));
const Footer = dynamic(() => import('./Footer'));
import type {
HeroTranslations,
AboutTranslations,
ProjectsTranslations,
ContactTranslations,
@@ -27,23 +29,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
return locale.startsWith('de') ? 'de' : 'en';
}
export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];
const messages = {
home: {
hero: baseMessages.home.hero
}
};
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<Hero />
</NextIntlClientProvider>
);
}
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
const normalLocale = getNormalizedLocale(locale);
const baseMessages = messageMap[normalLocale];

View File

@@ -54,8 +54,6 @@ export default function ConsentBanner() {
type="button"
onClick={() => setMinimized(true)}
className="shrink-0 text-xs text-stone-500 hover:text-stone-900 transition-colors"
aria-label="Minimize privacy banner"
title="Minimize"
>
{s.hide}
</button>

View File

@@ -5,8 +5,8 @@ import { motion } from "framer-motion";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "./RichTextClient";
import dynamic from "next/dynamic";
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
const Contact = () => {
const { showEmailSent, showEmailError } = useToast();
@@ -14,7 +14,7 @@ const Contact = () => {
const t = useTranslations("home.contact");
const tForm = useTranslations("home.contact.form");
const tInfo = useTranslations("home.contact.info");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => {
(async () => {
@@ -24,14 +24,14 @@ const Contact = () => {
);
const data = await res.json();
// Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
if (data?.content?.html && data?.content?.locale === locale) {
setCmsHtml(data.content.html as string);
} else {
setCmsDoc(null);
setCmsHtml(null);
}
} catch {
// ignore; fallback to static
setCmsDoc(null);
setCmsHtml(null);
}
})();
}, [locale]);
@@ -162,17 +162,14 @@ const Contact = () => {
{/* Header Card */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
<div className="max-w-3xl">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
{t("title")}<span className="text-liquid-mint">.</span>
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
{cmsDoc ? (
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
{cmsHtml ? (
<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-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")}
@@ -183,19 +180,16 @@ const Contact = () => {
{/* Info Side (Unified Connect Box) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
>
<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">
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Connect</h4>
<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-600 dark:text-emerald-400">Online</span>
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
</div>
</div>
@@ -203,7 +197,7 @@ const Contact = () => {
{/* 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-400 mb-1">Email</span>
<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">
@@ -216,7 +210,7 @@ const Contact = () => {
{/* 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-400 mb-1">Code</span>
<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">
@@ -229,7 +223,7 @@ const Contact = () => {
{/* 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-400 mb-1">Professional</span>
<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">
@@ -240,7 +234,7 @@ const Contact = () => {
</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-400 mb-2">Location</p>
<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>
@@ -251,9 +245,6 @@ const Contact = () => {
{/* Form Side */}
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }}
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
@@ -264,7 +255,7 @@ const Contact = () => {
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.name")}
</label>
<input
@@ -281,7 +272,7 @@ const Contact = () => {
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.email")}
</label>
<input
@@ -299,7 +290,7 @@ const Contact = () => {
</div>
<div className="space-y-2">
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.subject")}
</label>
<input
@@ -316,7 +307,7 @@ const Contact = () => {
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
{tForm("labels.message")}
</label>
<textarea

View File

@@ -33,14 +33,14 @@ const Footer = () => {
{/* Navigation Links */}
<div className="md:col-span-4 grid grid-cols-2 gap-8">
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Legal</p>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
<div className="flex flex-col gap-2">
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
</div>
</div>
<div className="space-y-4">
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Social</p>
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
<div className="flex flex-col gap-2">
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
@@ -52,9 +52,9 @@ const Footer = () => {
<div className="md:col-span-4 flex justify-start md:justify-end">
<button
onClick={scrollToTop}
className="group flex flex-col items-center gap-4 text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
>
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400 vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
<ArrowUp size={20} />
</div>
@@ -64,12 +64,12 @@ const Footer = () => {
{/* Bottom Bar */}
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
Built with Next.js, Directus & Passion.
</p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">Systems Online</span>
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
</div>
</div>
</div>

View File

@@ -1,30 +0,0 @@
"use client";
import { motion } from "framer-motion";
const Grain = () => {
return (
<div
className="pointer-events-none fixed inset-0 z-[9999] h-full w-full overflow-hidden"
>
<div
className="absolute inset-[-200%] h-[400%] w-[400%] bg-[url('/images/grain.png')] bg-repeat opacity-[0.03] dark:opacity-[0.05]"
style={{ transform: 'translate3d(0, 0, 0)' }}
/>
<motion.div
animate={{
x: [0, -50, 20, -10, 40, -20, 0],
y: [0, 20, -30, 10, -20, 30, 0],
}}
transition={{
duration: 0.5,
repeat: Infinity,
ease: "linear",
}}
className="absolute inset-[-200%] h-[400%] w-[400%] bg-[url('/images/grain.png')] bg-repeat opacity-[0.03] dark:opacity-[0.05]"
/>
</div>
);
};
export default Grain;

View File

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X } from "lucide-react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
@@ -26,11 +25,7 @@ const Header = () => {
return (
<>
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<motion.nav
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4"
>
<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">
{/* Logo Pill */}
<Link
href={`/${locale}`}
@@ -72,33 +67,30 @@ const Header = () => {
{isOpen ? <X size={14} /> : <Menu size={14} />}
</button>
</div>
</motion.nav>
</nav>
</div>
{/* Mobile Menu Overlay */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
className="fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden"
>
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
>
{item.name}
</Link>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<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 ${
isOpen
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 -translate-y-2 pointer-events-none"
}`}
>
<div className="flex flex-col gap-3">
{navItems.map((item) => (
<Link
key={item.name}
href={item.href}
onClick={() => setIsOpen(false)}
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
>
{item.name}
</Link>
))}
</div>
</div>
</>
);
};

View File

@@ -1,13 +1,22 @@
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X, Mail } from "lucide-react";
import { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations";
// Inline SVG icons to avoid loading the full lucide-react chunk (~116KB)
const MenuIcon = ({ size = 24 }: { size?: number }) => (
<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;
@@ -44,7 +53,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
href: "https://linkedin.com/in/dkonkol",
label: "LinkedIn",
},
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
{ icon: MailIcon, href: "mailto:contact@dk0.dev", label: "Email" },
];
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
@@ -55,53 +64,38 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
return (
<>
<motion.header
initial={false}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
>
<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"
}`}
>
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
<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"
}`}
>
<motion.div
whileHover={{ scale: 1.05 }}
className="flex items-center space-x-2"
>
<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>
</motion.div>
</div>
<nav className="hidden md:flex items-center space-x-8">
{navItems.map((item) => (
<motion.div
key={item.name}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
>
<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>
</motion.div>
</div>
))}
{/* Language Switcher */}
@@ -129,121 +123,109 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</div>
</nav>
<motion.button
whileHover={{ scale: 1.05, rotate: 90 }}
whileTap={{ scale: 0.95 }}
<button
onClick={() => setIsOpen(!isOpen)}
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors"
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-all hover:scale-105 active:scale-95"
aria-label="Toggle menu"
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</motion.button>
</motion.div>
{isOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
</button>
</div>
</div>
</motion.header>
</header>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
onClick={() => setIsOpen(false)}
/>
)}
</AnimatePresence>
{/* 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)}
/>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: "100%", opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: "100%", opacity: 0 }}
transition={{ type: "spring", damping: 30, stiffness: 300 }}
className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto"
>
<div className="p-6">
<div className="flex justify-between items-center mb-8">
<Link
href={`/${locale}`}
className="text-2xl font-black text-stone-900"
onClick={() => setIsOpen(false)}
>
dk<span className="text-red-500">0</span>
</Link>
<button
onClick={() => setIsOpen(false)}
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
aria-label="Close menu"
>
<X size={24} />
</button>
</div>
{/* 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"
<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}
>
{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>
<Icon size={20} />
</a>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,140 +1,73 @@
"use client";
import { motion } from "framer-motion";
import { useLocale, useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import Image from "next/image";
import { useEffect, useState } from "react";
const Hero = () => {
const locale = useLocale();
const t = useTranslations("home.hero");
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
interface HeroProps {
locale: string;
}
useEffect(() => {
(async () => {
try {
const res = await fetch(`/api/messages?locale=${locale}`);
if (res.ok) {
const data = await res.json();
setCmsMessages(data.messages || {});
}
} catch {}
})();
}, [locale]);
// Helper to get CMS text or fallback
const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
export default async function Hero({ locale: _locale }: HeroProps) {
const t = await getTranslations("home.hero");
return (
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
{/* Liquid Ambient Background */}
<div className="absolute inset-0 pointer-events-none">
<motion.div
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }}
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
/>
<motion.div
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
/>
<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">
{/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
<div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
</div>
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20">
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24">
<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">
<div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
{/* Left: Text Content */}
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
>
<div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
<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]">
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
</motion.div>
<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>
</div>
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
<motion.span
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.1 }}
className="block"
>
{getLabel("hero.line1", "Building")}
</motion.span>
<motion.span
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4"
>
{getLabel("hero.line2", "Stuff.")}
</motion.span>
<span className="block">
{t("line1")}
</span>
<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")}
</span>
</h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.4 }}
className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight"
>
<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">
{t("description")}
</motion.p>
</p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6 }}
className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4"
>
<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]">
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
{t("ctaWork")}
</a>
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors">
<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")}
</a>
</motion.div>
</div>
</div>
{/* Right: The Photo */}
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1
}}
transition={{
opacity: { duration: 1 },
scale: { duration: 1 }
}}
className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0"
>
<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 />
<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>
</motion.div>
</div>
</div>
</div>
<motion.div
animate={{ y: [0, 15, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4"
>
<div className="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" />
</motion.div>
</div>
</section>
);
};
export default Hero;
}

View File

@@ -52,7 +52,7 @@ const Projects = () => {
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
<div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
Selected Work<span className="text-liquid-mint">.</span>
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
</h2>
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
Projects that pushed my boundaries.
@@ -74,13 +74,14 @@ const Projects = () => {
</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
key={project.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="group relative"
>
<Link href={`/${locale}/projects/${project.slug}`} className="block">

View File

@@ -101,7 +101,12 @@ const ReadBooks = () => {
}
if (reviews.length === 0) {
return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen
return (
<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);

View File

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

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

@@ -1,112 +1,60 @@
"use client";
import React from "react";
import { ShaderGradientCanvas, ShaderGradient } from "@shadergradient/react";
const ShaderGradientBackground = () => {
// Pure CSS gradient background — replaces the Three.js/WebGL shader gradient.
// Server component: no "use client", zero JS bundle cost, renders in initial HTML.
// Visual result is identical since all original spheres had animate="off" (static).
export default function ShaderGradientBackground() {
return (
<div
aria-hidden="true"
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
inset: 0,
zIndex: -1,
filter: "blur(150px)",
opacity: 0.65,
overflow: "hidden",
pointerEvents: "none",
}}
>
<ShaderGradientCanvas
{/* Upper-left: crimson → pink (was Sphere 1: posX=-2.5, posY=1.5) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
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,
}}
>
{/* Sphere 1 - Links oben */}
<ShaderGradient
control="props"
type="sphere"
animate="on"
brightness={1.3}
cAzimuthAngle={180}
cDistance={3.6}
cPolarAngle={90}
cameraZoom={1}
color1="#b01040"
color2="#b04a17"
color3="#e167c5"
positionX={-2.5}
positionY={1.5}
positionZ={0}
rotationX={0}
rotationY={15}
rotationZ={50}
uAmplitude={6.0}
uDensity={0.8}
uFrequency={5.5}
uSpeed={0.5}
uStrength={5.0}
/>
{/* Sphere 2 - Rechts mitte */}
<ShaderGradient
control="props"
type="sphere"
animate="on"
brightness={1.25}
cAzimuthAngle={180}
cDistance={3.6}
cPolarAngle={90}
cameraZoom={1}
color1="#e167c5"
color2="#b01040"
color3="#b04a17"
positionX={2.0}
positionY={-0.5}
positionZ={-0.5}
rotationX={0}
rotationY={25}
rotationZ={70}
uAmplitude={5.5}
uDensity={0.9}
uFrequency={4.8}
uSpeed={0.45}
uStrength={4.8}
/>
{/* Sphere 3 - Unten links */}
<ShaderGradient
control="props"
type="sphere"
animate="on"
brightness={1.2}
cAzimuthAngle={180}
cDistance={3.6}
cPolarAngle={90}
cameraZoom={1}
color1="#b04a17"
color2="#e167c5"
color3="#b01040"
positionX={-0.5}
positionY={-2.0}
positionZ={0.3}
rotationX={0}
rotationY={20}
rotationZ={60}
uAmplitude={5.8}
uDensity={0.7}
uFrequency={6.0}
uSpeed={0.52}
uStrength={4.9}
/>
</ShaderGradientCanvas>
/>
{/* 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>
);
};
export default ShaderGradientBackground;
}

View File

@@ -1,11 +1,38 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import React, { createContext, useContext, useEffect, useState } from "react";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
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

@@ -2,8 +2,7 @@
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { motion } from "framer-motion";
import { useTheme } from "./ThemeProvider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
@@ -18,11 +17,9 @@ export function ThemeToggle() {
}
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
<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"
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" ? (
@@ -30,6 +27,6 @@ export function ThemeToggle() {
) : (
<Moon size={18} className="text-stone-600" />
)}
</motion.button>
</button>
);
}

View File

@@ -1,6 +1,5 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
@@ -11,15 +10,9 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
// Capture exception in Sentry
Sentry.captureException(error);
// Log error details to console
console.error("Global Error:", error);
console.error("Error Name:", error.name);
console.error("Error Message:", error.message);
console.error("Error Stack:", error.stack);
console.error("Error Digest:", error.digest);
if (process.env.NODE_ENV === "development") {
console.error("Global Error:", error);
}
}, [error]);
return (

View File

@@ -2,19 +2,6 @@
@tailwind components;
@tailwind utilities;
/* Grain Effect */
.grain-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
pointer-events: none;
opacity: 0.04;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
}
:root {
/* Warm Brown & Off-White Palette */

View File

@@ -3,9 +3,9 @@ import { Metadata } from "next";
import { Inter, Playfair_Display } from "next/font/google";
import React from "react";
import ClientProviders from "./components/ClientProviders";
import ShaderGradientBackground from "./components/ShaderGradientBackground";
import { cookies } from "next/headers";
import { getBaseUrl } from "@/lib/seo";
import ShaderGradientBackground from "./components/ShaderGradientBackground";
const inter = Inter({
variable: "--font-inter",
@@ -32,6 +32,8 @@ export default async function RootLayout({
<html lang={locale} suppressHydrationWarning>
<head>
<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>
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" />

View File

@@ -1,20 +1,18 @@
"use client";
import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient";
export default function LegalNotice() {
const locale = useLocale();
const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => {
(async () => {
@@ -23,8 +21,8 @@ export default function LegalNotice() {
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
if (data?.content?.html && data?.content?.locale === locale) {
setCmsHtml(data.content.html as string);
}
} catch {}
})();
@@ -36,11 +34,7 @@ export default function LegalNotice() {
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="mb-20"
>
<div className="animate-fade-in 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"
@@ -52,21 +46,16 @@ export default function LegalNotice() {
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Legal<span className="text-liquid-mint">.</span>
</h1>
</motion.div>
</div>
{/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Legal Content (Large Box) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
{cmsDoc ? (
<div className="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 ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} />
<RichTextClient html={cmsHtml} />
</div>
) : (
<div className="space-y-16">
@@ -91,7 +80,7 @@ export default function LegalNotice() {
</section>
</div>
)}
</motion.div>
</div>
{/* Sidebar Widgets */}
<div className="lg:col-span-4 space-y-8">

View File

@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { Lock, Loader2 } from 'lucide-react';
import ModernAdminDashboard from '@/components/ModernAdminDashboard';
@@ -259,10 +258,12 @@ const AdminPage = () => {
// Loading state
if (authState.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-[#795548]" />
<p className="text-[#5d4037]">Loading...</p>
<div className="min-h-screen flex items-center justify-center bg-stone-50 dark:bg-stone-950">
<div className="text-center space-y-4">
<div className="font-mono text-sm font-black tracking-tighter text-stone-900 dark:text-stone-50">
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>
);
@@ -271,26 +272,38 @@ const AdminPage = () => {
// Lockout state
if (authState.isLocked) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
<div className="text-center">
<div className="w-16 h-16 bg-[#fecaca] rounded-2xl flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-[#d84315]" />
<div className="min-h-screen flex items-center justify-center bg-stone-50 dark:bg-stone-950 px-6">
<div className="w-full max-w-sm">
<div className="bg-white dark:bg-stone-900 rounded-[2.5rem] border border-stone-200 dark:border-stone-800 overflow-hidden shadow-2xl">
<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>
<p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-4">
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
onClick={() => {
try {
localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
window.location.reload();
}}
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
</button>
</div>
</div>
<h2 className="text-2xl font-bold text-[#3e2723] mb-2">Account Locked</h2>
<p className="text-[#5d4037]">Too many failed attempts. Please try again in 15 minutes.</p>
<button
onClick={() => {
try {
localStorage.removeItem('admin_lockout');
} catch {
// Ignore errors
}
window.location.reload();
}}
className="mt-4 px-6 py-2 bg-[#5d4037] text-[#faf8f3] rounded-xl hover:bg-[#3e2723] transition-colors"
>
Try Again
</button>
</div>
</div>
);
@@ -299,70 +312,84 @@ const AdminPage = () => {
// Login form
if (authState.showLogin || !authState.isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#faf8f3] 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
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md p-6"
>
<div className="bg-[#fffcf5] backdrop-blur-xl rounded-3xl p-8 border border-[#d7ccc8] shadow-2xl relative z-10">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-[#efebe9] rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-sm border border-[#d7ccc8]">
<Lock className="w-6 h-6 text-[#5d4037]" />
</div>
<h1 className="text-2xl font-bold text-[#3e2723] mb-2 tracking-tight">Admin Access</h1>
<p className="text-[#5d4037]">Enter your password to continue</p>
</div>
<div className="relative z-10 w-full max-w-sm animate-[fadeIn_0.4s_ease-out]">
<div className="bg-white dark:bg-stone-900 rounded-[2.5rem] border border-stone-200 dark:border-stone-800 overflow-hidden shadow-2xl">
<div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
<form onSubmit={handleLogin} className="space-y-5">
<div>
<div className="relative">
<input
type={authState.showPassword ? 'text' : 'password'}
value={authState.password}
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
placeholder="Enter password"
className="w-full px-4 py-3.5 bg-white border border-[#d7ccc8] rounded-xl text-[#3e2723] placeholder:text-[#a1887f] focus:outline-none focus:ring-2 focus:ring-[#bcaaa4] focus:border-[#5d4037] transition-all shadow-sm"
disabled={authState.isLoading}
/>
<button
type="button"
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[#a1887f] hover:text-[#5d4037] p-1"
>
{authState.showPassword ? '👁️' : '👁️‍🗨️'}
</button>
<div className="p-10">
<div className="text-center mb-8">
<p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-5">
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>
{authState.error && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-[#d84315] text-sm font-medium flex items-center"
>
<span className="w-1.5 h-1.5 bg-[#d84315] rounded-full mr-2" />
{authState.error}
</motion.p>
)}
<h1 className="text-3xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 mb-1">
Admin Access
</h1>
<p className="text-stone-500 dark:text-stone-400 text-sm">
Enter your password to continue
</p>
</div>
<button
type="submit"
disabled={authState.isLoading || !authState.password}
className="w-full bg-[#5d4037] text-[#faf8f3] py-3.5 px-6 rounded-xl font-semibold text-lg hover:bg-[#3e2723] focus:outline-none focus:ring-2 focus:ring-[#bcaaa4] focus:ring-offset-2 focus:ring-offset-white disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg flex items-center justify-center"
>
{authState.isLoading ? (
<div className="flex items-center justify-center space-x-2">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-[#faf8f3]">Authenticating...</span>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<div className="relative">
<input
type={authState.showPassword ? 'text' : 'password'}
value={authState.password}
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
placeholder="Password"
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}
/>
<button
type="button"
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
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 ? '👁️' : '👁️‍🗨️'}
</button>
</div>
) : (
<span className="text-[#faf8f3]">Sign In</span>
)}
</button>
</form>
{authState.error && (
<p className="mt-2 text-red-500 text-sm font-medium flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
{authState.error}
</p>
)}
</div>
<button
type="submit"
disabled={authState.isLoading || !authState.password}
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 ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Authenticating
</>
) : (
'Sign In'
)}
</button>
</form>
</div>
</div>
</motion.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>
);
}

View File

@@ -1,20 +1,18 @@
"use client";
import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
import Header from "../components/Header";
import Footer from "../components/Footer";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient";
export default function PrivacyPolicy() {
const locale = useLocale();
const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => {
(async () => {
@@ -23,8 +21,8 @@ export default function PrivacyPolicy() {
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
);
const data = await res.json();
if (data?.content?.content && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent);
if (data?.content?.html && data?.content?.locale === locale) {
setCmsHtml(data.content.html as string);
}
} catch {}
})();
@@ -36,11 +34,7 @@ export default function PrivacyPolicy() {
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="mb-20"
>
<div className="animate-fade-in 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"
@@ -52,21 +46,16 @@ export default function PrivacyPolicy() {
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
Privacy<span className="text-liquid-purple">.</span>
</h1>
</motion.div>
</div>
{/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Privacy Text (Large) */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
{cmsDoc ? (
<div className="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 ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
<RichTextClient doc={cmsDoc} />
<RichTextClient html={cmsHtml} />
</div>
) : (
<div className="space-y-16">
@@ -89,7 +78,7 @@ export default function PrivacyPolicy() {
</section>
</div>
)}
</motion.div>
</div>
{/* Quick Info Cards */}
<div className="lg:col-span-4 space-y-8">

View File

@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react';
@@ -132,9 +133,11 @@ const ProjectDetail = () => {
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
>
{project.imageUrl ? (
<img
<Image
src={project.imageUrl}
alt={project.title}
fill
unoptimized
className="w-full h-full object-cover"
/>
) : (

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { motion } from 'framer-motion';
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { useLocale, useTranslations } from "next-intl";
@@ -159,9 +160,11 @@ const ProjectsPage = () => {
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? (
<>
<img
<Image
src={project.imageUrl}
alt={project.title}
fill
unoptimized
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" />

View File

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

View File

@@ -1,70 +0,0 @@
/* ghostContent.css */
.content {
font-family: "Arial", sans-serif;
line-height: 1.6;
color: #333;
}
.content h1,
.content h2,
.content h3,
.content h4,
.content h5,
.content h6 {
color: #222;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.content p {
margin-bottom: 1em;
}
.content img {
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
margin: 1em 0;
}
.content ul,
.content ol {
margin: 1em 0;
padding-left: 1.5em;
}
.content a {
color: #0070f3;
text-decoration: none;
}
.content a:hover {
text-decoration: underline;
}
.content blockquote {
border-left: 4px solid #ddd;
padding-left: 1em;
color: #666;
margin: 1em 0;
}
.content pre {
background: #f5f5f5;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
}
.loader {
border-top-color: #3498db;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

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

View File

@@ -1,7 +1,6 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Mail,
Settings,
@@ -21,23 +20,23 @@ import dynamic from 'next/dynamic';
const EmailManager = dynamic(
() => import('./EmailManager').then((m) => m.EmailManager),
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading emails</div> }
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading emails</div> }
);
const AnalyticsDashboard = dynamic(
() => import('./AnalyticsDashboard').then((m) => m.default),
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading analytics</div> }
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading analytics</div> }
);
const ImportExport = dynamic(
() => import('./ImportExport').then((m) => m.default),
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading tools</div> }
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading tools</div> }
);
const ProjectManager = dynamic(
() => import('./ProjectManager').then((m) => m.ProjectManager),
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading projects</div> }
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading projects</div> }
);
const ContentManager = dynamic(
() => import('./ContentManager').then((m) => m.default),
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading content</div> }
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading content</div> }
);
interface Project {
@@ -69,8 +68,10 @@ interface ModernAdminDashboardProps {
isAuthenticated?: boolean;
}
type TabId = 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings';
const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthenticated = true }) => {
const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings'>('overview');
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [projects, setProjects] = useState<Project[]>([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoading, setIsLoading] = useState(false);
@@ -180,7 +181,6 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
totalViews: ((analytics?.overview as Record<string, unknown>)?.totalViews as number) || (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0),
unreadEmails: emails.filter(e => !(e.read as boolean)).length,
avgPerformance: (() => {
// Only show real performance data, not defaults
const projectsWithPerf = projects.filter(p => {
const perf = p.performance as Record<string, unknown> || {};
return (perf.lighthouse as number || 0) > 0;
@@ -198,7 +198,6 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
};
useEffect(() => {
// Prioritize the data needed for the initial dashboard render
void (async () => {
await Promise.all([loadProjects(), loadSystemStats()]);
@@ -218,464 +217,421 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
}, [loadProjects, loadSystemStats, loadAnalytics, loadEmails]);
const navigation = [
{ id: 'overview', label: 'Dashboard', icon: Home, color: 'blue', description: 'Overview & Statistics' },
{ id: 'projects', label: 'Projects', icon: Database, color: 'green', description: 'Manage Projects' },
{ id: 'emails', label: 'Emails', icon: Mail, color: 'purple', description: 'Email Management' },
{ id: 'analytics', label: 'Analytics', icon: Activity, color: 'orange', description: 'Site Analytics' },
{ id: 'content', label: 'Content', icon: Shield, color: 'teal', description: 'Texts, pages & localization' },
{ id: 'settings', label: 'Settings', icon: Settings, color: 'gray', description: 'System Settings' }
{ id: 'overview' as TabId, label: 'Dashboard', icon: Home, description: 'Overview & Statistics' },
{ id: 'projects' as TabId, label: 'Projects', icon: Database, description: 'Manage Projects' },
{ id: 'emails' as TabId, label: 'Emails', icon: Mail, description: 'Email Management' },
{ id: 'analytics' as TabId, label: 'Analytics', icon: Activity, description: 'Site Analytics' },
{ id: 'content' as TabId, label: 'Content', icon: Shield, description: 'Texts, pages & localization' },
{ id: 'settings' as TabId, label: 'Settings', icon: Settings, description: 'System Settings' }
];
const statCards = [
{
label: 'Projects',
value: stats.totalProjects,
sub: `${stats.publishedProjects} published`,
icon: Database,
tab: 'projects' as TabId,
gradient: 'from-emerald-400/20 to-emerald-400/5',
border: 'border-emerald-400/20 dark:border-emerald-400/10',
iconColor: 'text-emerald-500',
tooltip: 'REAL DATA: Total projects in your portfolio from the database. Shows published vs unpublished count.',
},
{
label: 'Page Views',
value: stats.totalViews.toLocaleString(),
sub: `${stats.totalUsers} users`,
icon: Activity,
tab: 'analytics' as TabId,
gradient: 'from-sky-400/20 to-sky-400/5',
border: 'border-sky-400/20 dark:border-sky-400/10',
iconColor: 'text-sky-500',
tooltip: 'REAL DATA: Total page views from PageView table (last 30 days). Users = unique IP addresses.',
},
{
label: 'Messages',
value: emails.length,
sub: stats.unreadEmails > 0 ? `${stats.unreadEmails} unread` : 'all read',
subColor: stats.unreadEmails > 0 ? 'text-red-500' : 'text-emerald-500',
icon: Mail,
tab: 'emails' as TabId,
gradient: 'from-purple-400/20 to-purple-400/5',
border: 'border-purple-400/20 dark:border-purple-400/10',
iconColor: 'text-purple-500',
},
{
label: 'Performance',
value: stats.avgPerformance || 'N/A',
sub: 'Lighthouse score',
icon: TrendingUp,
tab: 'analytics' as TabId,
gradient: 'from-amber-400/20 to-amber-400/5',
border: 'border-amber-400/20 dark:border-amber-400/10',
iconColor: 'text-amber-500',
tooltip: stats.avgPerformance > 0
? 'REAL DATA: Average Lighthouse score from real Web Vitals collected from page visits.'
: 'No performance data yet. Scores appear after visitors load pages.',
},
{
label: 'Bounce Rate',
value: `${stats.bounceRate}%`,
sub: 'Exit rate',
icon: Users,
tab: 'analytics' as TabId,
gradient: 'from-pink-400/20 to-pink-400/5',
border: 'border-pink-400/20 dark:border-pink-400/10',
iconColor: 'text-pink-500',
tooltip: 'REAL DATA: Percentage of sessions with only 1 pageview. Lower is better.',
},
{
label: 'System',
value: 'Online',
sub: 'Operational',
icon: Shield,
tab: 'settings' as TabId,
gradient: 'from-teal-400/20 to-teal-400/5',
border: 'border-teal-400/20 dark:border-teal-400/10',
iconColor: 'text-teal-500',
pulse: true,
},
];
return (
<div className="min-h-screen">
{/* Animated Background - same as main site */}
<div className="fixed inset-0 animated-bg"></div>
<div className="min-h-screen bg-stone-50 dark:bg-stone-950">
{/* Admin Navbar - Horizontal Navigation */}
<div className="relative z-10">
<div className="admin-glass border-b border-white/20 sticky top-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Left side - Logo and Admin Panel */}
<div className="flex items-center space-x-4">
<Link
href="/"
className="flex items-center space-x-2 text-stone-900 hover:text-black transition-colors"
>
<Home size={20} className="text-stone-600" />
<span className="font-medium text-stone-900">Portfolio</span>
</Link>
<div className="h-6 w-px bg-stone-300" />
<div className="flex items-center space-x-2">
<Shield size={20} className="text-stone-600" />
<span className="text-stone-900 font-semibold">Admin Panel</span>
</div>
</div>
{/* Navbar */}
<div className="sticky top-0 z-50 bg-stone-50/90 dark:bg-stone-950/90 backdrop-blur-xl border-b border-stone-200 dark:border-stone-800">
{/* Gradient accent bar */}
<div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
{/* Center - Desktop Navigation */}
<div className="hidden md:flex items-center space-x-1">
{navigation.map((item) => (
<button
key={item.id}
onClick={() => setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings')}
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 ${
activeTab === item.id
? 'bg-stone-100 text-stone-900 font-medium shadow-sm border border-stone-200'
: 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
}`}
>
<item.icon size={16} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} />
<span className="text-sm">{item.label}</span>
</button>
))}
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-14">
{/* Right side - User info and Logout */}
<div className="flex items-center space-x-4">
<div className="hidden sm:block text-sm text-stone-500">
Welcome, <span className="text-stone-800 font-semibold">Dennis</span>
</div>
{/* Left: branding */}
<div className="flex items-center gap-4">
<Link
href="/"
className="flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-stone-50 transition-colors"
>
<Home size={16} />
<span className="font-mono text-xs font-black tracking-tighter">
dk<span className="text-red-500">0</span>.dev
</span>
</Link>
<div className="h-4 w-px bg-stone-300 dark:bg-stone-700" />
<span className="font-black text-xs uppercase tracking-[0.15em] text-stone-900 dark:text-stone-50">
Admin
</span>
</div>
{/* Center: desktop tabs */}
<div className="hidden md:flex items-center gap-1">
{navigation.map((item) => (
<button
onClick={async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
window.location.href = '/manage';
} catch (error) {
console.error('Logout failed:', error);
// Force logout anyway
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
window.location.href = '/manage';
}
}}
className="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-red-50 text-stone-500 hover:text-red-600 transition-all duration-200 border border-transparent hover:border-red-100"
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-[0.1em] transition-all duration-200 ${
activeTab === item.id
? 'bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900'
: 'text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-50 hover:bg-stone-100 dark:hover:bg-stone-900'
}`}
>
<LogOut size={16} />
<span className="hidden sm:inline text-sm font-medium">Logout</span>
<item.icon size={13} />
{item.label}
</button>
))}
</div>
{/* Mobile menu button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden flex items-center justify-center p-2 rounded-lg text-stone-600 hover:bg-stone-100 transition-colors"
>
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
{/* Right: user + logout + mobile toggle */}
<div className="flex items-center gap-3">
<span className="hidden sm:block text-xs text-stone-400 dark:text-stone-500 font-mono">
Dennis
</span>
<button
onClick={async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
window.location.href = '/manage';
} catch (error) {
console.error('Logout failed:', error);
sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token');
window.location.href = '/manage';
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-[0.1em] text-stone-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20 transition-all duration-200"
>
<LogOut size={13} />
<span className="hidden sm:inline">Logout</span>
</button>
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 rounded-xl text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-900 transition-colors"
>
{mobileMenuOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</div>
</div>
{/* Mobile Navigation Menu */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden border-t border-stone-200 bg-white"
>
<div className="px-4 py-4 space-y-2">
{navigation.map((item) => (
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-stone-200 dark:border-stone-800 bg-stone-50 dark:bg-stone-950">
<div className="px-4 py-3 grid grid-cols-2 gap-2">
{navigation.map((item) => (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id);
setMobileMenuOpen(false);
}}
className={`flex items-center gap-2 px-4 py-3 rounded-2xl transition-all duration-200 text-left ${
activeTab === item.id
? 'bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900'
: 'text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-50 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800'
}`}
>
<item.icon size={16} />
<div>
<div className="font-bold text-xs uppercase tracking-[0.1em]">{item.label}</div>
<div className="text-[10px] opacity-60">{item.description}</div>
</div>
</button>
))}
</div>
</div>
)}
</div>
{/* Main content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8">
{/* Overview tab */}
{activeTab === 'overview' && (
<div className="space-y-8">
<div>
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
Dashboard<span className="text-emerald-500">.</span>
</h1>
<p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">
Manage your portfolio and monitor performance
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{statCards.map((card) => (
<div
key={card.label}
className={`relative group bg-gradient-to-br ${card.gradient} border ${card.border} rounded-3xl p-5 cursor-pointer hover:scale-[1.02] active:scale-[0.98] transition-all duration-200`}
onClick={() => setActiveTab(card.tab)}
>
<div className="flex items-start justify-between mb-3">
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-stone-500 dark:text-stone-400">
{card.label}
</p>
<card.icon size={15} className={card.iconColor} />
</div>
<p className="text-2xl font-black tracking-tighter text-stone-900 dark:text-stone-50">
{card.pulse ? (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
{card.value}
</span>
) : card.value}
</p>
<p className={`text-[11px] font-medium mt-1 ${card.subColor ?? 'text-stone-500 dark:text-stone-400'}`}>
{card.sub}
</p>
{card.tooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 dark:bg-stone-800 text-stone-50 text-[10px] font-medium rounded-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-[200px] z-50 shadow-xl pointer-events-none text-center">
{card.tooltip}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 dark:bg-stone-800 rotate-45" />
</div>
)}
</div>
))}
</div>
{/* Recent Activity + Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Activity */}
<div className="lg:col-span-2 bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50">
Recent Activity
</h2>
<button
onClick={() => loadAllData()}
className="text-xs font-bold uppercase tracking-[0.1em] text-stone-400 hover:text-stone-900 dark:hover:text-stone-50 px-3 py-1.5 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-200 dark:border-stone-700 transition-all"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Projects</h3>
{projects.slice(0, 3).map((project) => (
<div
key={project.id}
className="flex items-start gap-3 p-3 bg-stone-50 dark:bg-stone-800/50 border border-stone-100 dark:border-stone-700 rounded-2xl hover:border-stone-300 dark:hover:border-stone-600 transition-all cursor-pointer"
onClick={() => setActiveTab('projects')}
>
<div className="flex-1 min-w-0">
<p className="text-stone-900 dark:text-stone-50 font-bold text-sm truncate">{project.title}</p>
<p className="text-stone-400 text-xs mt-0.5">{project.analytics?.views || 0} views</p>
<div className="flex items-center gap-2 mt-2">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${project.published ? 'bg-emerald-100 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400' : 'bg-amber-100 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400'}`}>
{project.published ? 'Live' : 'Draft'}
</span>
{project.featured && (
<span className="px-2 py-0.5 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-full text-[10px] font-bold">Featured</span>
)}
</div>
</div>
</div>
))}
{projects.length === 0 && (
<p className="text-stone-400 text-xs py-4">No projects yet</p>
)}
</div>
<div className="space-y-3">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Messages</h3>
{emails.slice(0, 3).map((email, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 bg-stone-50 dark:bg-stone-800/50 border border-stone-100 dark:border-stone-700 rounded-2xl hover:border-stone-300 dark:hover:border-stone-600 transition-all cursor-pointer"
onClick={() => setActiveTab('emails')}
>
<div className="w-8 h-8 bg-gradient-to-br from-purple-400/20 to-purple-400/5 border border-purple-400/20 rounded-xl flex items-center justify-center flex-shrink-0">
<Mail size={13} className="text-purple-500" />
</div>
<div className="flex-1 min-w-0">
<p className="text-stone-900 dark:text-stone-50 font-bold text-sm truncate">
{email.name as string}
</p>
<p className="text-stone-400 text-xs truncate">{(email.subject as string) || 'No subject'}</p>
</div>
{!(email.read as boolean) && (
<div className="w-2 h-2 bg-red-500 rounded-full flex-shrink-0" />
)}
</div>
))}
{emails.length === 0 && (
<p className="text-stone-400 text-xs py-4">No messages yet</p>
)}
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50 mb-6">
Quick Actions
</h2>
<div className="space-y-3">
{[
{ label: 'Ghost Editor', sub: 'Professional writing tool', icon: Plus, action: () => window.location.href = '/editor', color: 'from-emerald-400/20 to-emerald-400/5 border-emerald-400/20' },
{ label: 'View Messages', sub: `${stats.unreadEmails} unread`, icon: Mail, action: () => setActiveTab('emails'), color: 'from-purple-400/20 to-purple-400/5 border-purple-400/20' },
{ label: 'Analytics', sub: 'View detailed stats', icon: TrendingUp, action: () => setActiveTab('analytics'), color: 'from-sky-400/20 to-sky-400/5 border-sky-400/20' },
{ label: 'Settings', sub: 'System configuration', icon: Settings, action: () => setActiveTab('settings'), color: 'from-stone-400/20 to-stone-400/5 border-stone-400/20' },
].map((item) => (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings');
setMobileMenuOpen(false);
}}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 ${
activeTab === item.id
? 'bg-stone-100 text-stone-900 shadow-sm border border-stone-200'
: 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
}`}
key={item.label}
onClick={item.action}
className={`w-full flex items-center gap-3 p-3 bg-gradient-to-r ${item.color} border rounded-2xl hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 text-left`}
>
<item.icon size={18} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} />
<div className="text-left">
<div className="font-medium text-sm">{item.label}</div>
<div className="text-xs opacity-70">{item.description}</div>
<div className="w-9 h-9 bg-white dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 flex items-center justify-center flex-shrink-0">
<item.icon size={15} className="text-stone-600 dark:text-stone-300" />
</div>
<div>
<p className="text-stone-900 dark:text-stone-50 font-bold text-sm">{item.label}</p>
<p className="text-stone-400 text-xs">{item.sub}</p>
</div>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
)}
{/* Main Content - Full Width Horizontal Layout */}
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-6 lg:py-8">
{/* Content */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'overview' && (
<div className="space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-stone-900">Admin Dashboard</h1>
<p className="text-stone-500 text-lg">Manage your portfolio and monitor performance</p>
</div>
</div>
{activeTab === 'projects' && (
<div className="space-y-6">
<div>
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
Projects<span className="text-emerald-500">.</span>
</h1>
<p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">Manage your portfolio projects</p>
</div>
<ProjectManager projects={projects} onProjectsChange={loadProjects} />
</div>
)}
{/* Stats Grid - Mobile: 2x3, Desktop: 6x1 horizontal */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 md:gap-6">
{activeTab === 'emails' && (
<EmailManager />
)}
{activeTab === 'analytics' && (
<AnalyticsDashboard isAuthenticated={isAuthenticated} />
)}
{activeTab === 'content' && (
<ContentManager />
)}
{activeTab === 'settings' && (
<div className="space-y-8">
<div>
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
Settings<span className="text-emerald-500">.</span>
</h1>
<p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">Manage system configuration and preferences</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50 mb-2">
Import / Export
</h2>
<p className="text-stone-400 text-sm mb-6">Backup and restore your portfolio data</p>
<ImportExport />
</div>
<div className="bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50 mb-6">
System Status
</h2>
<div className="space-y-3">
{[
{ label: 'Database', color: 'bg-emerald-400/20 border-emerald-400/20', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'Redis Cache', color: 'bg-emerald-400/20 border-emerald-400/20', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'API Services', color: 'bg-emerald-400/20 border-emerald-400/20', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' },
].map((item) => (
<div
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('projects')}
key={item.label}
className={`flex items-center justify-between p-4 bg-gradient-to-r ${item.color} border rounded-2xl`}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-stone-500 text-xs md:text-sm font-medium">Projects</p>
<Database size={20} className="text-stone-400" />
</div>
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.totalProjects}</p>
<p className="text-stone-600 text-xs font-medium">{stats.publishedProjects} published</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Total projects in your portfolio from the database. Shows published vs unpublished count.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
<span className="text-stone-600 dark:text-stone-300 font-medium text-sm">{item.label}</span>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 ${item.dot} rounded-full animate-pulse`} />
<span className={`${item.text} font-bold text-sm`}>Online</span>
</div>
</div>
<div
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('analytics')}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-stone-500 text-xs md:text-sm font-medium">Page Views</p>
<Activity size={20} className="text-stone-400" />
</div>
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.totalViews.toLocaleString()}</p>
<p className="text-stone-600 text-xs font-medium">{stats.totalUsers} users</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Total page views from PageView table (last 30 days). Each visit is tracked with IP, user agent, and timestamp. Users = unique IP addresses.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
</div>
<div
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none"
onClick={() => setActiveTab('emails')}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-stone-500 text-xs md:text-sm font-medium">Messages</p>
<Mail size={20} className="text-stone-400" />
</div>
<p className="text-xl md:text-2xl font-bold text-stone-900">{emails.length}</p>
<p className="text-red-500 text-xs font-medium">{stats.unreadEmails} unread</p>
</div>
</div>
<div
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('analytics')}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-stone-500 text-xs md:text-sm font-medium">Performance</p>
<TrendingUp size={20} className="text-stone-400" />
</div>
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.avgPerformance || 'N/A'}</p>
<p className="text-stone-600 text-xs font-medium">Lighthouse Score</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
{stats.avgPerformance > 0
? "✅ REAL DATA: Average Lighthouse score (0-100) calculated from real Web Vitals (LCP, FCP, CLS, FID, TTFB) collected from actual page visits. Only averages projects with real performance data."
: "No performance data yet. Scores appear after visitors load pages and Web Vitals are tracked."}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
</div>
<div
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
onClick={() => setActiveTab('analytics')}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-stone-500 text-xs md:text-sm font-medium">Bounce Rate</p>
<Users size={20} className="text-stone-400" />
</div>
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.bounceRate}%</p>
<p className="text-stone-600 text-xs font-medium">Exit rate</p>
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Percentage of sessions with only 1 pageview (calculated from PageView records grouped by IP). Lower is better. Shows how many visitors leave after viewing just one page.
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
</div>
<div
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none"
onClick={() => setActiveTab('settings')}
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<p className="text-stone-500 text-xs md:text-sm font-medium">System</p>
<Shield size={20} className="text-stone-400" />
</div>
<p className="text-xl md:text-2xl font-bold text-stone-900">Online</p>
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<p className="text-stone-600 text-xs font-medium">Operational</p>
</div>
</div>
</div>
</div>
{/* Recent Activity & Quick Actions - Mobile: vertical, Desktop: horizontal */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Recent Activity */}
<div className="admin-glass-card p-6 rounded-xl md:col-span-2">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-stone-900">Recent Activity</h2>
<button
onClick={() => loadAllData()}
className="text-stone-500 hover:text-stone-800 text-sm font-medium px-3 py-1 bg-stone-100 rounded-lg transition-colors border border-stone-200"
>
Refresh
</button>
</div>
{/* Mobile: vertical stack, Desktop: horizontal columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-6">
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-wider">Projects</h3>
<div className="space-y-4">
{projects.slice(0, 3).map((project) => (
<div key={project.id} className="flex items-start space-x-3 p-4 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('projects')}>
<div className="flex-1 min-w-0">
<p className="text-stone-800 font-medium text-sm truncate">{project.title}</p>
<p className="text-stone-500 text-xs">{project.published ? 'Published' : 'Draft'} {project.analytics?.views || 0} views</p>
<div className="flex items-center space-x-2 mt-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${project.published ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>
{project.published ? 'Live' : 'Draft'}
</span>
{project.featured && (
<span className="px-2 py-1 bg-stone-200 text-stone-700 rounded-full text-xs font-medium">Featured</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-4">
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-wider">Messages</h3>
<div className="space-y-3">
{emails.slice(0, 3).map((email, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('emails')}>
<div className="w-8 h-8 bg-stone-200 rounded-lg flex items-center justify-center flex-shrink-0">
<Mail size={14} className="text-stone-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-stone-800 font-medium text-sm truncate">From {email.name as string}</p>
<p className="text-stone-500 text-xs truncate">{(email.subject as string) || 'No subject'}</p>
</div>
{!(email.read as boolean) && (
<div className="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"></div>
)}
</div>
))}
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="admin-glass-card p-6 rounded-xl">
<h2 className="text-xl font-bold text-stone-900 mb-6">Quick Actions</h2>
<div className="space-y-4">
<button
onClick={() => window.location.href = '/editor'}
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
>
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Plus size={18} className="text-stone-600" />
</div>
<div>
<p className="text-stone-800 font-medium text-sm">Ghost Editor</p>
<p className="text-stone-500 text-xs">Professional writing tool</p>
</div>
</button>
<button
onClick={() => setActiveTab('analytics')}
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
>
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Activity size={18} className="text-stone-600" />
</div>
<div>
<p className="text-stone-800 font-medium text-sm">Reset Analytics</p>
<p className="text-stone-500 text-xs">Clear analytics data</p>
</div>
</button>
<button
onClick={() => setActiveTab('emails')}
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
>
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Mail size={18} className="text-stone-600" />
</div>
<div>
<p className="text-stone-800 font-medium text-sm">View Messages</p>
<p className="text-stone-500 text-xs">{stats.unreadEmails} unread messages</p>
</div>
</button>
<button
onClick={() => setActiveTab('analytics')}
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
>
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<TrendingUp size={18} className="text-stone-600" />
</div>
<div>
<p className="text-stone-800 font-medium text-sm">Analytics</p>
<p className="text-stone-500 text-xs">View detailed statistics</p>
</div>
</button>
<button
onClick={() => setActiveTab('settings')}
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
>
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
<Settings size={18} className="text-stone-600" />
</div>
<div>
<p className="text-stone-800 font-medium text-sm">Settings</p>
<p className="text-stone-500 text-xs">System configuration</p>
</div>
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{activeTab === 'projects' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-stone-900">Project Management</h2>
<p className="text-stone-500 mt-1">Manage your portfolio projects</p>
</div>
</div>
<ProjectManager projects={projects} onProjectsChange={loadProjects} />
</div>
)}
{activeTab === 'emails' && (
<EmailManager />
)}
{activeTab === 'analytics' && (
<AnalyticsDashboard isAuthenticated={isAuthenticated} />
)}
{activeTab === 'content' && (
<ContentManager />
)}
{activeTab === 'settings' && (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold text-stone-900">System Settings</h1>
<p className="text-stone-500">Manage system configuration and preferences</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="admin-glass-card p-6 rounded-xl">
<h2 className="text-xl font-bold text-stone-900 mb-4">Import / Export</h2>
<p className="text-stone-500 mb-4">Backup and restore your portfolio data</p>
<ImportExport />
</div>
<div className="admin-glass-card p-6 rounded-xl">
<h2 className="text-xl font-bold text-stone-900 mb-4">System Status</h2>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
<span className="text-stone-600">Database</span>
<div className="flex items-center space-x-3">
<div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-green-600 font-medium">Online</span>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
<span className="text-stone-600">Redis Cache</span>
<div className="flex items-center space-x-3">
<div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-green-600 font-medium">Online</span>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
<span className="text-stone-600">API Services</span>
<div className="flex items-center space-x-3">
<div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-green-600 font-medium">Online</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
);

View File

@@ -13,7 +13,7 @@ This document provides an assessment of the portfolio website's production readi
- [x] Input sanitization on forms
- [x] SQL injection protection (Prisma ORM)
- [x] XSS protection via React and sanitize-html
- [x] Error monitoring with Sentry.io
- [x] Error logging in development mode
### Performance
- [x] Next.js App Router with Server Components
@@ -42,10 +42,8 @@ This document provides an assessment of the portfolio website's production readi
- [x] Analytics opt-in (Umami - privacy-friendly)
- [x] Data processing documentation
- [x] Contact form with consent
- [x] Sentry.io mentioned in privacy policy
### Monitoring & Observability
- [x] Sentry.io error tracking (configured)
- [x] Umami analytics (self-hosted, privacy-friendly)
- [x] Health check endpoint (`/api/health`)
- [x] Logging infrastructure
@@ -79,15 +77,6 @@ This document provides an assessment of the portfolio website's production readi
- Locations: Hero.tsx, CurrentlyReading.tsx, Projects pages
- Benefit: Better performance, automatic optimization
2. **Configure Sentry.io DSN**
- Set `NEXT_PUBLIC_SENTRY_DSN` in production environment
- Set `SENTRY_AUTH_TOKEN` for source map uploads
- Get DSN from: https://sentry.io/settings/dk0/projects/portfolio/keys/
3. **Review CSP for Sentry**
- May need to adjust Content-Security-Policy headers to allow Sentry
- Add `connect-src` directive for `*.sentry.io`
### Medium Priority
1. **Accessibility audit**
- Run Lighthouse audit
@@ -105,7 +94,6 @@ This document provides an assessment of the portfolio website's production readi
### Low Priority
1. **Enhanced monitoring**
- Custom Sentry contexts for better debugging
- Performance metrics dashboard
2. **Advanced features**
@@ -123,10 +111,6 @@ Before deploying to production:
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
# Sentry (Recommended)
NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/...
SENTRY_AUTH_TOKEN=...
# Email (Optional)
MY_EMAIL=...
MY_PASSWORD=...
@@ -156,7 +140,6 @@ Before deploying to production:
- Test HTTPS redirect
6. **Monitoring**
- Verify Sentry is receiving events
- Check Umami analytics tracking
- Test health endpoint
@@ -200,13 +183,12 @@ The application is production-ready with the following notes:
3. **Performance**: Optimized for production
4. **SEO**: Properly configured for search engines
5. **Privacy**: GDPR-compliant with privacy policy
6. **Monitoring**: Sentry.io configured (needs DSN in production)
6. **Monitoring**: Umami analytics (self-hosted)
**Next Steps**:
1. Configure Sentry.io DSN in production environment
2. Replace `<img>` tags with Next.js `<Image />` for optimal performance
3. Run final accessibility audit
4. Monitor performance metrics after deployment
1. Replace `<img>` tags with Next.js `<Image />` for optimal performance
2. Run final accessibility audit
3. Monitor performance metrics after deployment
---

View File

@@ -48,6 +48,4 @@ PRISMA_AUTO_BASELINE=false
# SKIP_PRISMA_MIGRATE=true
# Monitoring (optional)
NEXT_PUBLIC_SENTRY_DSN=your-sentry-dsn
SENTRY_AUTH_TOKEN=your-sentry-auth-token
LOG_LEVEL=info

View File

@@ -1,32 +1,2 @@
// This file configures the initialization of Sentry on the client.
// The added config here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
// DSN from environment variable with fallback to wizard-provided value
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
// Add optional integrations for additional features
integrations: [Sentry.replayIntegration()],
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
// Enable logs to be sent to Sentry
enableLogs: true,
// Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// Define how likely Replay events are sampled when an error occurs.
replaysOnErrorSampleRate: 1.0,
// Enable sending user PII (Personally Identifiable Information)
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
sendDefaultPii: true,
});
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
// Client-side instrumentation hook for Next.js
// Add any client-side instrumentation here if needed

View File

@@ -1,13 +1,4 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
// Instrumentation hook for Next.js
// Add any server-side instrumentation here if needed
}
export const onRequestError = Sentry.captureRequestError;

View File

@@ -993,3 +993,83 @@ export async function getSnippets(limit = 10, featured?: boolean): Promise<Snipp
return null;
}
}
// ─── Hardcover → Directus sync helpers ───────────────────────────────────────
export interface BookReviewCreate {
hardcover_id: string;
book_title: string;
book_author: string;
book_image?: string;
rating?: number;
finished_at?: string;
status: 'published' | 'draft';
}
/**
* Check if a book review already exists in Directus by Hardcover ID.
* Used for deduplication during sync.
*/
export async function getBookReviewByHardcoverId(
hardcoverId: string
): Promise<{ id: string } | null> {
const query = `
query {
book_reviews(
filter: { hardcover_id: { _eq: "${hardcoverId}" } }
limit: 1
) {
id
}
}
`;
try {
const result = await directusRequest<{ book_reviews: Array<{ id: string }> }>(
'',
{ body: { query } }
);
const item = result?.book_reviews?.[0];
return item ?? null;
} catch {
return null;
}
}
/**
* Create a new book review in the Directus book_reviews collection via REST API.
* Returns the created item id, or null on failure.
*/
export async function createBookReview(
data: BookReviewCreate
): Promise<{ id: string } | null> {
if (!DIRECTUS_TOKEN) return null;
try {
const response = await fetch(`${DIRECTUS_URL}/items/book_reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
},
body: JSON.stringify(data),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
const text = await response.text();
if (process.env.NODE_ENV === 'development') {
console.error(`Directus create book_review failed ${response.status}:`, text.slice(0, 200));
}
return null;
}
const result = await response.json();
return result?.data?.id ? { id: result.data.id } : null;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('createBookReview error:', error);
}
return null;
}
}

View File

@@ -29,8 +29,12 @@ function getCached(key: string): unknown | null {
}
/**
* Get a localized message by key
* Tries: Directus (requested locale) → Directus (EN) → JSON (requested locale) → JSON (EN)
* Get a localized message by key.
* Tries: JSON (requested locale) → JSON (EN) → Directus (requested locale) → Directus (EN)
*
* JSON is checked first so that translation-heavy server components never wait on
* a Directus network round-trip for keys that already exist in the message files.
* Directus is only queried when the key is absent from JSON (i.e. CMS-only content).
*/
export async function getLocalizedMessage(
key: string,
@@ -40,31 +44,16 @@ export async function getLocalizedMessage(
const cached = getCached(cacheKey);
if (cached !== null) return cached as string;
// Try Directus with requested locale
const dbValue = await getMessage(key, locale);
if (dbValue) {
setCached(cacheKey, dbValue);
return dbValue;
}
// Fallback to EN in Directus if not EN already
if (locale !== 'en') {
const dbValueEn = await getMessage(key, 'en');
if (dbValueEn) {
setCached(cacheKey, dbValueEn);
return dbValueEn;
}
}
// Fallback to JSON file (normalize locale to 'en' or 'de')
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
// 1) JSON requested locale
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
if (jsonValue) {
setCached(cacheKey, jsonValue);
return jsonValue;
}
// Fallback to EN JSON
// 2) JSON EN fallback
if (normalizedLocale !== 'en') {
const jsonValueEn = getNestedValue(jsonFallback['en'], key);
if (jsonValueEn) {
@@ -73,7 +62,23 @@ export async function getLocalizedMessage(
}
}
// Fallback: return the key itself
// 3) Directus only for keys missing from JSON (CMS-only content)
const dbValue = await getMessage(key, locale);
if (dbValue) {
setCached(cacheKey, dbValue);
return dbValue;
}
// 4) Directus EN fallback
if (locale !== 'en') {
const dbValueEn = await getMessage(key, 'en');
if (dbValueEn) {
setCached(cacheKey, dbValueEn);
return dbValueEn;
}
}
// 5) Return the key itself as last resort
return key;
}

View File

@@ -26,6 +26,9 @@
},
"home": {
"hero": {
"badge": "Student & Self-Hoster",
"line1": "Building",
"line2": "Stuff.",
"features": {
"f1": "Next.js & Flutter",
"f2": "Docker Swarm & CI/CD",
@@ -69,7 +72,8 @@
"title": "Gelesene Bücher",
"finishedAt": "Beendet am",
"showMore": "{count} weitere anzeigen",
"showLess": "Weniger anzeigen"
"showLess": "Weniger anzeigen",
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
},
"activity": {
"idleStatus": "System im Leerlauf / Geist aktiv",

View File

@@ -27,6 +27,9 @@
,
"home": {
"hero": {
"badge": "Student & Self-Hoster",
"line1": "Building",
"line2": "Stuff.",
"features": {
"f1": "Next.js & Flutter",
"f2": "Docker Swarm & CI/CD",
@@ -70,7 +73,8 @@
"title": "Read",
"finishedAt": "Finished",
"showMore": "{count} more",
"showLess": "Show less"
"showLess": "Show less",
"empty": "Books finished in Hardcover will appear here automatically."
},
"activity": {
"idleStatus": "System Idle / Mind Active",

View File

@@ -70,9 +70,7 @@ export function middleware(request: NextRequest) {
pathname.startsWith("/api/") ||
pathname === "/api" ||
pathname.startsWith("/manage") ||
pathname.startsWith("/editor") ||
pathname === "/sentry-example-page" ||
pathname.startsWith("/sentry-example-page/");
pathname.startsWith("/editor");
// Locale routing for public site pages
const responseUrl = request.nextUrl.clone();
@@ -88,11 +86,15 @@ export function middleware(request: NextRequest) {
return addHeaders(request, res);
}
// Redirect bare routes to locale-prefixed ones
// Add locale prefix: rewrite root path (avoids redirect roundtrip), redirect others
const preferred = pickLocaleFromHeader(request.headers.get("accept-language"));
const redirectTarget =
pathname === "/" ? `/${preferred}` : `/${preferred}${pathname}${search || ""}`;
responseUrl.pathname = redirectTarget;
if (pathname === "/") {
responseUrl.pathname = `/${preferred}`;
const res = NextResponse.rewrite(responseUrl);
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
return addHeaders(request, res);
}
responseUrl.pathname = `/${preferred}${pathname}${search || ""}`;
const res = NextResponse.redirect(responseUrl);
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
return addHeaders(request, res);

View File

@@ -3,8 +3,6 @@ import dotenv from "dotenv";
import path from "path";
import bundleAnalyzer from "@next/bundle-analyzer";
import createNextIntlPlugin from "next-intl/plugin";
import { withSentryConfig } from "@sentry/nextjs";
// Load the .env file from the working directory
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
@@ -33,20 +31,22 @@ const nextConfig: NextConfig = {
},
// Performance optimizations
experimental:
process.env.NODE_ENV === "production"
? {
optimizePackageImports: ["lucide-react", "framer-motion"],
}
: {
// In development, enable webpack build worker for faster builds
webpackBuildWorker: true,
},
experimental: {
// Tree-shake barrel-file packages in both dev and production
optimizePackageImports: ["lucide-react", "framer-motion", "react-icons", "@tiptap/react"],
// Merge all CSS into a single chunk to eliminate the render-blocking CSS chain
// (84dc7384.css → 3aefc04b.css sequential dependency reported by PageSpeed).
cssChunking: false,
// Note: optimizeCss (critters) is intentionally disabled — it converts the main
// <link rel="stylesheet"> to a JS-deferred preload, which PageSpeed reads as a
// sequential CSS chain and reports 410ms of render-blocking.
...(process.env.NODE_ENV !== "production" ? { webpackBuildWorker: true } : {}),
},
// Image optimization
images: {
formats: ["image/webp", "image/avif"],
minimumCacheTTL: 60,
minimumCacheTTL: 2592000,
remotePatterns: [
{
protocol: "https",
@@ -97,17 +97,6 @@ const nextConfig: NextConfig = {
};
if (!isServer) {
// Optimize module concatenation and chunking for the client build
config.optimization = {
...config.optimization,
moduleIds: "deterministic",
chunkIds: "deterministic",
splitChunks: {
...config.optimization?.splitChunks,
maxSize: 200000, // keep chunks <200KB to avoid large-string serialization
},
};
// Suppress framer-motion source map errors in development
config.plugins.push(
new webpack.SourceMapDevToolPlugin({
@@ -169,7 +158,35 @@ const nextConfig: NextConfig = {
],
},
{
source: "/api/(.*)",
// Allow bfcache for n8n routes: use no-cache (revalidate) instead of no-store
source: "/api/n8n/(.*)",
headers: [
{
key: "Cache-Control",
value: "no-cache, must-revalidate",
},
],
},
{
source: "/api/auth/(.*)",
headers: [
{
key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
},
],
},
{
source: "/api/email/(.*)",
headers: [
{
key: "Cache-Control",
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
},
],
},
{
source: "/api/contacts/(.*)",
headers: [
{
key: "Cache-Control",
@@ -200,42 +217,4 @@ const withBundleAnalyzer = bundleAnalyzer({
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
// Wrap with Sentry
export default withSentryConfig(
withBundleAnalyzer(withNextIntl(nextConfig)),
{
// For all available options, see:
// https://github.com/getsentry/sentry-webpack-plugin#options
org: "dk0",
project: "portfolio",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
tunnelRoute: "/monitoring",
// Webpack-specific options
webpack: {
// Automatically annotate React components to show their full name in breadcrumbs and session replay
reactComponentAnnotation: {
enabled: true,
},
// Automatically tree-shake Sentry logger statements to reduce bundle size
treeshake: {
removeDebugLogging: true,
},
// Enables automatic instrumentation of Vercel Cron Monitors
automaticVercelMonitors: true,
},
// Source maps configuration
sourcemaps: {
disable: false,
},
}
);
export default withBundleAnalyzer(withNextIntl(nextConfig));

3091
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,9 +54,6 @@
"dependencies": {
"@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.22.0",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.36.0",
"@shadergradient/react": "^2.4.20",
"@swc/helpers": "^0.5.19",
"@tiptap/extension-color": "^3.15.3",
"@tiptap/extension-highlight": "^3.15.3",
@@ -73,7 +70,6 @@
"lucide-react": "^0.542.0",
"next": "^15.5.7",
"next-intl": "^4.7.0",
"next-themes": "^0.4.6",
"node-cache": "^5.1.2",
"node-fetch": "^2.7.0",
"nodemailer": "^7.0.11",
@@ -83,9 +79,14 @@
"react-markdown": "^10.1.0",
"redis": "^5.8.2",
"sanitize-html": "^2.17.0",
"tailwind-merge": "^2.6.0",
"three": "^0.183.1"
"tailwind-merge": "^2.6.0"
},
"browserslist": [
"chrome >= 100",
"firefox >= 100",
"safari >= 15.4",
"edge >= 100"
],
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.57.0",
@@ -100,6 +101,7 @@
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.0",
"autoprefixer": "^10.4.24",
"critters": "^0.0.23",
"cross-env": "^7.0.3",
"eslint": "^9",
"eslint-config-next": "^15.5.7",

View File

@@ -134,7 +134,17 @@ async function main() {
// If baseline fails we continue to migrate deploy, which will surface the real issue.
}
}
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
const migrateResult = spawnSync(
"node",
["node_modules/prisma/build/index.js", "migrate", "deploy"],
{ stdio: "inherit", env: process.env }
);
if (migrateResult.status !== 0) {
console.error(
`[startup] prisma migrate deploy failed (exit ${migrateResult.status}). Starting server anyway...`
);
}
} else {
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
}

View File

@@ -1,16 +0,0 @@
// This file configures the initialization of Sentry for edge features (middleware, etc).
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
// DSN from environment variable with fallback to wizard-provided value
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@@ -1,16 +0,0 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
// DSN from environment variable with fallback to wizard-provided value
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View File

@@ -11,6 +11,20 @@ export default {
],
theme: {
extend: {
keyframes: {
fadeIn: {
"0%": { opacity: "0", transform: "translateY(10px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
slideDown: {
"0%": { opacity: "0", transform: "translateY(-20px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"fade-in": "fadeIn 0.5s ease-out",
"slide-down": "slideDown 0.4s ease-out",
},
colors: {
background: "var(--background)",
foreground: "var(--foreground)",