Compare commits

..

66 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
32e621df14 fix: namespace rate limit buckets per endpoint, remove custom analytics
Some checks failed
Gitea CI / test-build (push) Failing after 5m21s
Dev Deployment (Zero Downtime) / deploy-dev (push) Successful in 18m29s
- Add `prefix` param to checkRateLimit/getRateLimitHeaders so each endpoint
  has its own bucket (previously all shared `admin_${ip}`, causing 429s when
  analytics/track incremented past n8n endpoints' lower limits)
- n8n/hardcover/currently-reading → prefix 'n8n-reading'
- n8n/status → prefix 'n8n-status'
- analytics/track → prefix 'analytics-track'
- Remove custom analytics system (AnalyticsProvider, lib/analytics,
  lib/useWebVitals, all /api/analytics/* routes) — was causing 500s in
  production due to missing PostgreSQL PageView table
- Remove analytics consent toggle from ConsentBanner/ConsentProvider

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 10:52:18 +01:00
Dennis Konkol
e0e0551a83 ci: disable broken auto-deploy workflows, keep gitea CI only
Some checks failed
Gitea CI / test-build (push) Failing after 4m47s
2026-02-24 19:49:13 +00:00
Dennis Konkol
97c600df14 ci: disable GitHub workflow and add Gitea Actions workflow
Some checks failed
Gitea CI / test-build (push) Failing after 4m49s
Production Deployment (Zero Downtime) / deploy-production (push) Failing after 8m18s
2026-02-24 18:54:31 +00:00
denshooter
6c47cdbd83 Merge branch 'dev' into production
All checks were successful
Production Deployment (Zero Downtime) / deploy-production (push) Successful in 12m52s
2026-02-23 23:20:22 +01:00
denshooter
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
98 changed files with 2878 additions and 8574 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.

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

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

View File

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

View File

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

9
.gitignore vendored
View File

@@ -1,5 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Local tooling
.claude/settings.local.json
.claude/CLAUDE.local.md
._*
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
@@ -33,10 +38,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# Sentry
.sentryclirc
sentry.properties
# vercel # vercel
.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 ## 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 ## 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 - **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
- **Theming**: `next-themes` for Dark Mode support (system/light/dark) - **Theming**: `next-themes` for Dark Mode support (system/light/dark)
- **Animations**: Framer Motion 12 - **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 - **Database**: PostgreSQL via Prisma ORM
- **Cache**: Redis (optional) - **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) - **Automation**: n8n webhooks (status, chat, hardcover, image generation)
- **i18n**: next-intl (EN + DE), message files in `messages/` - **i18n**: next-intl (EN + DE), message files in `messages/`
- **Monitoring**: Sentry - **Deployment**: Docker + Nginx, CI via Gitea Actions (`output: "standalone"`)
- **Deployment**: Docker + Nginx, CI via Gitea Actions
## Commands ## 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:simple # Next.js only (no Docker)
npm run dev:next # Plain Next.js dev server npm run dev:next # Plain Next.js dev server
npm run build # Production build npm run build # Production build
npm run lint # ESLint npm run lint # ESLint (0 errors required, warnings OK)
npm run test # Jest unit tests 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 test:e2e # Playwright E2E tests
npm run db:generate # Regenerate Prisma client after schema changes
``` ```
## Project Structure ## Architecture
``` ### Server/Client Component Split
app/
[locale]/ # i18n routes (en, de)
page.tsx # Homepage (hero, about, projects, contact)
projects/ # Project listing + detail pages
api/ # API routes
book-reviews/ # Book reviews from Directus CMS
content/ # CMS content pages
hobbies/ # Hobbies from Directus
n8n/ # n8n webhook proxies
hardcover/ # Currently reading (Hardcover API via n8n)
status/ # Activity status (coding, music, gaming)
chat/ # AI chatbot
generate-image/ # AI image generation
projects/ # Projects API (PostgreSQL + Directus fallback)
tech-stack/ # Tech stack from Directus
components/ # React components
About.tsx # About section (tech stack, hobbies, books)
CurrentlyReading.tsx # Currently reading widget (n8n/Hardcover)
ReadBooks.tsx # Read books with ratings (Directus CMS)
Projects.tsx # Featured projects section
Hero.tsx # Hero section
Contact.tsx # Contact form
lib/
directus.ts # Directus GraphQL client (no SDK)
auth.ts # Auth utilities + rate limiting
prisma/
schema.prisma # Database schema
messages/
en.json # English translations
de.json # German translations
docs/ # Documentation
```
## Architecture Patterns The homepage uses a **server component orchestrator** pattern:
### Data Source Hierarchy (Fallback Chain) - `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps below-fold sections in `ScrollFadeIn`
1. Directus CMS (if configured via `DIRECTUS_STATIC_TOKEN`) - `app/components/Hero.tsx`**server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
2. PostgreSQL (for projects, analytics) - `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`; each wraps its component in a scoped `NextIntlClientProvider` with only the needed translation namespace
3. JSON files (`messages/*.json`) - `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
4. Hardcoded defaults
5. Display key itself as last resort
All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable. ### 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) ### 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` - Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
- Translations use Directus native translation system (M2O to `languages`) - Translations use Directus native M2O system; locale mapping: `en``en-US`, `de``de-DE`
- 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 ### 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 - Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
- Client components with `"use client"` for interactive/data-fetching parts - Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
- `useEffect` for data loading on mount - All endpoints have rate limiting and 10s timeout
- `useTranslations` from next-intl for i18n - Hardcover reading data cached 5 minutes
- Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
- Gradient cards with `liquid-*` color tokens and `backdrop-blur-sm`
## Design System ## Design System
@@ -103,54 +82,54 @@ Custom Tailwind colors prefixed with `liquid-`:
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink` - `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime` - `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 ## Key Environment Variables
```bash ```bash
# Required for CMS
DIRECTUS_URL=https://cms.dk0.dev DIRECTUS_URL=https://cms.dk0.dev
DIRECTUS_STATIC_TOKEN=... DIRECTUS_STATIC_TOKEN=...
# Required for n8n features
N8N_WEBHOOK_URL=https://n8n.dk0.dev N8N_WEBHOOK_URL=https://n8n.dk0.dev
N8N_SECRET_TOKEN=... N8N_SECRET_TOKEN=...
N8N_API_KEY=... N8N_API_KEY=...
# Database
DATABASE_URL=postgresql://... DATABASE_URL=postgresql://...
REDIS_URL=redis://... # optional
# Optional
REDIS_URL=redis://...
SENTRY_DSN=...
``` ```
## Conventions ## Adding a CMS-managed Section
- Language: Code in English, user-facing text via i18n (EN + DE) 1. Define GraphQL query + types in `lib/directus.ts`
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`) 2. Create API route `app/api/<name>/route.ts` with `runtime='nodejs'`, `dynamic='force-dynamic'`, and `source` field in response
- Components: PascalCase files in `app/components/` 3. Create component `app/components/<Name>.tsx` with Skeleton loading state
- API routes: kebab-case directories in `app/api/` 4. Add i18n keys to both `messages/en.json` and `messages/de.json`
- CMS data always has a static fallback - never rely solely on Directus 5. Create `<Name>Client` wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider`
- Error logging: Only in `development` mode (`process.env.NODE_ENV === "development"`) 6. Add to `app/_ui/HomePageServer.tsx` wrapped in `<ScrollFadeIn>`
- No emojis in code unless explicitly requested
## Common Tasks
### Adding a new CMS-managed section
1. Define the GraphQL query + types in `lib/directus.ts`
2. Create an API route in `app/api/<name>/route.ts`
3. Create a component in `app/components/<Name>.tsx`
4. Add i18n keys to `messages/en.json` and `messages/de.json`
5. Integrate into the parent component (usually `About.tsx`)
### Adding i18n strings
1. Add keys to `messages/en.json` and `messages/de.json`
2. Access via `useTranslations("key.path")` in client components
3. Or `getTranslations("key.path")` in server components
### Working with Directus collections
- All queries go through `directusRequest()` in `lib/directus.ts`
- Uses GraphQL endpoint (`/graphql`)
- 2-second timeout, graceful null fallback
- Translations filtered by `languages_code.code` matching Directus locale

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,13 +46,7 @@ export default function ProjectDetailClient({
setCanGoBack(true); setCanGoBack(true);
} }
try { }, []);
navigator.sendBeacon?.(
"/api/analytics/track",
new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }),
);
} catch {}
}, [project.id, project.slug, locale]);
const handleBack = (e: React.MouseEvent) => { const handleBack = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();

View File

@@ -1,174 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma, projectService } from '@/lib/prisma';
import { analyticsCache } from '@/lib/redis';
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Rate limiting - more generous for admin dashboard
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
return new NextResponse(
JSON.stringify({ error: 'Rate limit exceeded' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
...getRateLimitHeaders(ip, 20, 60000)
}
}
);
}
// Admin-only endpoint: require explicit admin header AND a valid signed session token
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
const authError = requireSessionAuth(request);
if (authError) return authError;
// Check cache first (but allow bypass with cache-bust parameter)
const url = new URL(request.url);
const bypassCache = url.searchParams.get('nocache') === 'true';
if (!bypassCache) {
const cachedStats = await analyticsCache.getOverallStats();
if (cachedStats) {
return NextResponse.json(cachedStats);
}
}
// Get analytics data
const projectsResult = await projectService.getAllProjects();
const projects = projectsResult.projects || projectsResult;
const performanceStats = await projectService.getPerformanceStats();
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Use DB aggregation instead of loading every PageView row into memory
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
prisma.pageView.groupBy({
by: ['ip'],
where: {
timestamp: { gte: since },
ip: { not: null },
},
_count: { _all: true },
_min: { timestamp: true },
_max: { timestamp: true },
}),
prisma.pageView.groupBy({
by: ['projectId'],
where: {
timestamp: { gte: since },
projectId: { not: null },
},
_count: { _all: true },
}),
]);
const totalSessions = sessionsByIp.length;
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
const sessionDurationsMs = sessionsByIp
.map(s => {
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
if (count < 2) return 0;
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
if (!minTs || !maxTs) return 0;
return maxTs.getTime() - minTs.getTime();
})
.filter(ms => ms > 0);
const avgSessionDuration = sessionDurationsMs.length > 0
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
: 0;
const totalUsers = totalSessions;
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
const projectId = row.projectId as number | null;
if (projectId != null) {
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
}
return acc;
}, {} as Record<number, number>);
// Calculate analytics metrics
const analytics = {
overview: {
totalProjects: projects.length,
publishedProjects: projects.filter(p => p.published).length,
featuredProjects: projects.filter(p => p.featured).length,
totalViews, // Real views from PageView table
totalLikes: 0, // Not implemented - no like buttons
totalShares: 0, // Not implemented - no share buttons
avgLighthouse: (() => {
// Only calculate if we have real performance data (not defaults)
const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
const lighthouse = perf.lighthouse as number || 0;
return lighthouse > 0; // Only count projects with actual performance data
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
: 0;
})()
},
projects: projects.map(project => ({
id: project.id,
title: project.title,
category: project.category,
difficulty: project.difficulty,
views: viewsByProject[project.id] || 0, // Only real views from PageView table
likes: 0, // Not implemented
shares: 0, // Not implemented
lighthouse: (() => {
const perf = (project.performance as Record<string, unknown>) || {};
const score = perf.lighthouse as number || 0;
return score > 0 ? score : 0; // Only return if we have real data
})(),
published: project.published,
featured: project.featured,
createdAt: project.createdAt,
updatedAt: project.updatedAt
})),
categories: performanceStats.byCategory,
difficulties: performanceStats.byDifficulty,
performance: {
avgLighthouse: (() => {
const projectsWithPerf = projects.filter(p => {
const perf = (p.performance as Record<string, unknown>) || {};
return (perf.lighthouse as number || 0) > 0;
});
return projectsWithPerf.length > 0
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
: 0;
})(),
totalViews, // Real total views
totalLikes: 0,
totalShares: 0
},
metrics: {
bounceRate,
avgSessionDuration,
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
newUsers: totalUsers,
totalUsers
}
};
// Cache the results
await analyticsCache.setOverallStats(analytics);
return NextResponse.json(analytics);
} catch (error) {
console.error('Analytics dashboard error:', error);
return NextResponse.json(
{ error: 'Failed to fetch analytics data' },
{ status: 500 }
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,9 @@ import { getBookReviews } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth'; import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/** /**
* GET /api/book-reviews * GET /api/book-reviews
@@ -25,31 +27,29 @@ export async function GET(request: NextRequest) {
const locale = searchParams.get('locale') || 'en'; const locale = searchParams.get('locale') || 'en';
const reviews = await getBookReviews(locale); const reviews = await getBookReviews(locale);
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
console.log(`[API] Book Reviews geladen für ${locale}:`, reviews?.length || 0); console.log(`[API] Book Reviews geladen für ${locale}:`, reviews?.length || 0);
} }
if (reviews && reviews.length > 0) { if (reviews && reviews.length > 0) {
return NextResponse.json({ return NextResponse.json(
bookReviews: reviews, { bookReviews: reviews, source: 'directus' },
source: 'directus' { headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
}); );
} }
return NextResponse.json({ return NextResponse.json(
bookReviews: null, { bookReviews: null, source: 'fallback' },
source: 'fallback' { headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
}); );
} catch (error) { } 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( 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 } { status: 500 }
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,18 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getMessages } from "@/lib/directus"; import { getMessages } from "@/lib/directus";
const CACHE_TTL = 300; // 5 minutes
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const locale = searchParams.get("locale") || "en"; const locale = searchParams.get("locale") || "en";
try { try {
const messages = await getMessages(locale); 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 { } catch {
return NextResponse.json({ messages: {} }, { status: 500 }); return NextResponse.json({ messages: {} }, { status: 500 });
} }

View File

@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
: ip; : ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10; const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute
return NextResponse.json( return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' }, { error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 } { status: 429 }

View File

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

View File

@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
: ip; : ip;
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30; const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute
return NextResponse.json( return NextResponse.json(
{ error: 'Rate limit exceeded. Please try again later.' }, { error: 'Rate limit exceeded. Please try again later.' },
{ status: 429 } { status: 429 }

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

View File

@@ -3,13 +3,15 @@ import { getTechStack } from '@/lib/directus';
import { checkRateLimit, getClientIp } from '@/lib/auth'; import { checkRateLimit, getClientIp } from '@/lib/auth';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
export const dynamic = 'force-dynamic'; export const revalidate = 300;
const CACHE_TTL = 300; // 5 minutes
/** /**
* GET /api/tech-stack * GET /api/tech-stack
* *
* Loads Tech Stack from Directus with fallback to static data * Loads Tech Stack from Directus with fallback to static data
* *
* Query params: * Query params:
* - locale: en or de (default: en) * - locale: en or de (default: en)
*/ */
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
const techStack = await getTechStack(locale); const techStack = await getTechStack(locale);
if (techStack && techStack.length > 0) { if (techStack && techStack.length > 0) {
return NextResponse.json({ return NextResponse.json(
techStack, { techStack, source: 'directus' },
source: 'directus' { headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
}); );
} }
// Fallback: return empty (component will use hardcoded fallback) // Fallback: return empty (component will use hardcoded fallback)
return NextResponse.json({ return NextResponse.json(
techStack: null, { techStack: null, source: 'fallback' },
source: 'fallback' { headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
}); );
} catch (error) { } 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( 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 } { status: 500 }
); );
} }

View File

@@ -3,8 +3,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react"; import { 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 { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic";
import RichTextClient from "./RichTextClient"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
import CurrentlyReading from "./CurrentlyReading"; import CurrentlyReading from "./CurrentlyReading";
import ReadBooks from "./ReadBooks"; import ReadBooks from "./ReadBooks";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
@@ -22,29 +22,28 @@ const iconMap: Record<string, LucideIcon> = {
const About = () => { const About = () => {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("home.about"); const t = useTranslations("home.about");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
const [techStack, setTechStack] = useState<TechStackCategory[]>([]); const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
const [hobbies, setHobbies] = useState<Hobby[]>([]); const [hobbies, setHobbies] = useState<Hobby[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]); const [snippets, setSnippets] = useState<Snippet[]>([]);
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null); const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({}); const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { 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/content/page?key=home-about&locale=${locale}`),
fetch(`/api/tech-stack?locale=${locale}`), fetch(`/api/tech-stack?locale=${locale}`),
fetch(`/api/hobbies?locale=${locale}`), fetch(`/api/hobbies?locale=${locale}`),
fetch(`/api/messages?locale=${locale}`), fetch(`/api/messages?locale=${locale}`),
fetch(`/api/book-reviews?locale=${locale}`),
fetch(`/api/snippets?limit=3&featured=true`) fetch(`/api/snippets?limit=3&featured=true`)
]); ]);
const cmsData = await cmsRes.json(); 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(); const techData = await techRes.json();
if (techData?.techStack) setTechStack(techData.techStack); if (techData?.techStack) setTechStack(techData.techStack);
@@ -57,9 +56,6 @@ const About = () => {
const snippetsData = await snippetsRes.json(); const snippetsData = await snippetsRes.json();
if (snippetsData?.snippets) setSnippets(snippetsData.snippets); if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
await booksRes.json();
// Books data is available but we don't need to track count anymore
} catch (error) { } catch (error) {
console.error("About data fetch failed:", error); console.error("About data fetch failed:", error);
} finally { } finally {
@@ -83,14 +79,11 @@ const About = () => {
{/* 1. Large Bio Text */} {/* 1. Large Bio Text */}
<motion.div <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" 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"> <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"> <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> </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"> <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 ? ( {isLoading ? (
@@ -99,15 +92,15 @@ const About = () => {
<Skeleton className="h-6 w-[95%]" /> <Skeleton className="h-6 w-[95%]" />
<Skeleton className="h-6 w-[90%]" /> <Skeleton className="h-6 w-[90%]" />
</div> </div>
) : cmsDoc ? ( ) : cmsHtml ? (
<RichTextClient doc={cmsDoc} /> <RichTextClient html={cmsHtml} />
) : ( ) : (
<p>{t("p1")} {t("p2")}</p> <p>{t("p1")} {t("p2")}</p>
)} )}
</div> </div>
<div className="pt-4 sm:pt-6 md:pt-8"> <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"> <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>} {isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
</div> </div>
</div> </div>
@@ -116,9 +109,6 @@ const About = () => {
{/* 2. Activity / Status Box */} {/* 2. Activity / Status Box */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }} 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" 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"
> >
@@ -126,16 +116,13 @@ const About = () => {
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint"> <h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
<Activity size={20} /> Status <Activity size={20} /> Status
</h3> </h3>
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} /> <ActivityFeed locale={locale} />
</div> </div>
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" /> <div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
</motion.div> </motion.div>
{/* 3. AI Chat Box */} {/* 3. AI Chat Box */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }} 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" 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 */} {/* 4. Tech Stack */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }} 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" 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) => ( techStack.map((cat) => (
<div key={cat.id} className="space-y-6"> <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"> <div className="flex flex-wrap gap-2">
{cat.items?.map((item: TechStackItem) => ( {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"> <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"> <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 */} {/* Library - Larger Span */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.4 }} 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]" 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"> <div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
{/* My Gear (Uses) */} {/* My Gear (Uses) */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }} 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" 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>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.6 }} 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" 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)} 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" 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> <p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
</button> </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>
</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" /> Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
</Link> </Link>
</motion.div> </motion.div>
@@ -285,9 +260,6 @@ const About = () => {
{/* 6. Hobbies */} {/* 6. Hobbies */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.5 }} transition={{ delay: 0.5 }}
className="md:col-span-12" 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"> <div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
<button <button
onClick={() => setSelectedSnippet(null)} 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 Close Laboratory
</button> </button>

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react"; import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image";
interface CustomActivity { interface CustomActivity {
[key: string]: unknown; [key: string]: unknown;
@@ -22,14 +23,28 @@ const techQuotes = {
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" }, { content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" }, { content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" }, { content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" } { content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" },
{ content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" },
{ content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" },
{ content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" },
{ content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" },
{ content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" },
{ content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" },
{ content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" },
], ],
en: [ en: [
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" }, { content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" }, { content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" }, { content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" }, { content: "Deleted code is debugged code.", author: "Jeff Sickel" },
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" } { content: "First, solve the problem. Then, write the code.", author: "John Johnson" },
{ content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" },
{ content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" },
{ content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" },
{ content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" },
{ content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" },
{ content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" },
{ content: "The only way to go fast is to go well.", author: "Robert C. Martin" },
] ]
}; };
@@ -39,30 +54,20 @@ function getSafeGamingText(details: string | number | undefined, state: string |
return fallback; return fallback;
} }
export default function ActivityFeed({ export default function ActivityFeed({
onActivityChange, onActivityChange,
idleQuote,
locale = 'en' locale = 'en'
}: { }: {
onActivityChange?: (active: boolean) => void; onActivityChange?: (active: boolean) => void;
idleQuote?: string;
locale?: string; locale?: string;
}) { }) {
const [data, setData] = useState<StatusData | null>(null); const [data, setData] = useState<StatusData | null>(null);
const [hasActivity, setHasActivity] = useState(false); const [hasActivity, setHasActivity] = useState(false);
const [quoteIndex, setQuoteIndex] = useState(0); const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const t = useTranslations("home.about.activity"); const t = useTranslations("home.about.activity");
const currentQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en; const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
// Combine CMS quote with tech quotes if available
const allQuotes = React.useMemo(() => {
if (idleQuote) {
return [{ content: idleQuote, author: "Dennis Konkol" }, ...currentQuotes];
}
return currentQuotes;
}, [idleQuote, currentQuotes]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@@ -92,9 +97,13 @@ export default function ActivityFeed({
fetchData(); fetchData();
const statusInterval = setInterval(fetchData, 30000); const statusInterval = setInterval(fetchData, 30000);
// Cycle quotes every 10 seconds // Pick a random quote every 10 seconds (never the same one twice in a row)
const quoteInterval = setInterval(() => { const quoteInterval = setInterval(() => {
setQuoteIndex((prev) => (prev + 1) % allQuotes.length); setQuoteIndex((prev) => {
let next;
do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1);
return next;
});
}, 10000); }, 10000);
return () => { return () => {
@@ -125,7 +134,7 @@ export default function ActivityFeed({
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
className="space-y-4" 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; &ldquo;{allQuotes[quoteIndex].content}&rdquo;
</p> </p>
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest"> <p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
@@ -164,7 +173,7 @@ export default function ActivityFeed({
<div className="flex gap-4"> <div className="flex gap-4">
{data.gaming.image && ( {data.gaming.image && (
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative"> <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>
)} )}
<div className="min-w-0 flex flex-col justify-center"> <div className="min-w-0 flex flex-col justify-center">
@@ -207,10 +216,12 @@ export default function ActivityFeed({
</div> </div>
<div className="flex gap-4 relative z-10"> <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"> <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} src={data.music.albumArt}
alt="Album Art" 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>
<div className="min-w-0 flex flex-col justify-center"> <div className="min-w-0 flex flex-col justify-center">

View File

@@ -105,7 +105,7 @@ export default function BentoChat() {
placeholder="Ask me..." 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" 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} /> <Send size={18} />
</button> </button>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,8 @@ import { motion } from "framer-motion";
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react"; import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import type { JSONContent } from "@tiptap/react"; import dynamic from "next/dynamic";
import RichTextClient from "./RichTextClient"; const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
const Contact = () => { const Contact = () => {
const { showEmailSent, showEmailError } = useToast(); const { showEmailSent, showEmailError } = useToast();
@@ -14,7 +14,7 @@ const Contact = () => {
const t = useTranslations("home.contact"); const t = useTranslations("home.contact");
const tForm = useTranslations("home.contact.form"); const tForm = useTranslations("home.contact.form");
const tInfo = useTranslations("home.contact.info"); const tInfo = useTranslations("home.contact.info");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -24,14 +24,14 @@ const Contact = () => {
); );
const data = await res.json(); const data = await res.json();
// Only use CMS content if it exists for the active locale. // Only use CMS content if it exists for the active locale.
if (data?.content?.content && data?.content?.locale === locale) { if (data?.content?.html && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent); setCmsHtml(data.content.html as string);
} else { } else {
setCmsDoc(null); setCmsHtml(null);
} }
} catch { } catch {
// ignore; fallback to static // ignore; fallback to static
setCmsDoc(null); setCmsHtml(null);
} }
})(); })();
}, [locale]); }, [locale]);
@@ -162,17 +162,14 @@ const Contact = () => {
{/* Header Card */} {/* Header Card */}
<motion.div <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" 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"> <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"> <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> </h2>
{cmsDoc ? ( {cmsHtml ? (
<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" /> <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"> <p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
{t("subtitle")} {t("subtitle")}
@@ -183,19 +180,16 @@ const Contact = () => {
{/* Info Side (Unified Connect Box) */} {/* Info Side (Unified Connect Box) */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }} transition={{ delay: 0.1 }}
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6" 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="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="relative z-10">
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12"> <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"> <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="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>
</div> </div>
@@ -203,7 +197,7 @@ const Contact = () => {
{/* Email */} {/* Email */}
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link"> <a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
<div className="flex flex-col"> <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> <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>
<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"> <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 */} {/* GitHub */}
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link"> <a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
<div className="flex flex-col"> <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> <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>
<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"> <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 */} {/* LinkedIn */}
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link"> <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"> <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> <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>
<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"> <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>
<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"> <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"> <div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
<MapPin size={14} className="text-liquid-mint" /> <MapPin size={14} className="text-liquid-mint" />
<span className="font-bold">{tInfo("locationValue")}</span> <span className="font-bold">{tInfo("locationValue")}</span>
@@ -251,9 +245,6 @@ const Contact = () => {
{/* Form Side */} {/* Form Side */}
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.2 }} 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" 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"> <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="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
<div className="space-y-2"> <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")} {tForm("labels.name")}
</label> </label>
<input <input
@@ -281,7 +272,7 @@ const Contact = () => {
</div> </div>
<div className="space-y-2"> <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")} {tForm("labels.email")}
</label> </label>
<input <input
@@ -299,7 +290,7 @@ const Contact = () => {
</div> </div>
<div className="space-y-2"> <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")} {tForm("labels.subject")}
</label> </label>
<input <input
@@ -316,7 +307,7 @@ const Contact = () => {
</div> </div>
<div className="space-y-2"> <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")} {tForm("labels.message")}
</label> </label>
<textarea <textarea

View File

@@ -33,14 +33,14 @@ const Footer = () => {
{/* Navigation Links */} {/* Navigation Links */}
<div className="md:col-span-4 grid grid-cols-2 gap-8"> <div className="md:col-span-4 grid grid-cols-2 gap-8">
<div className="space-y-4"> <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"> <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}/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> <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> </div>
<div className="space-y-4"> <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"> <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://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> <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"> <div className="md:col-span-4 flex justify-start md:justify-end">
<button <button
onClick={scrollToTop} 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"> <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} /> <ArrowUp size={20} />
</div> </div>
@@ -64,12 +64,12 @@ const Footer = () => {
{/* Bottom Bar */} {/* 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"> <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. Built with Next.js, Directus & Passion.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" /> <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> </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"; "use client";
import { useState } from "react"; import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
@@ -26,11 +25,7 @@ const Header = () => {
return ( return (
<> <>
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none"> <div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
<motion.nav <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">
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"
>
{/* Logo Pill */} {/* Logo Pill */}
<Link <Link
href={`/${locale}`} href={`/${locale}`}
@@ -63,7 +58,7 @@ const Header = () => {
{locale === "en" ? "DE" : "EN"} {locale === "en" ? "DE" : "EN"}
</Link> </Link>
<ThemeToggle /> <ThemeToggle />
{/* Mobile Menu Toggle */} {/* Mobile Menu Toggle */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
@@ -72,33 +67,30 @@ const Header = () => {
{isOpen ? <X size={14} /> : <Menu size={14} />} {isOpen ? <X size={14} /> : <Menu size={14} />}
</button> </button>
</div> </div>
</motion.nav> </nav>
</div> </div>
{/* Mobile Menu Overlay */} {/* Mobile Menu Overlay */}
<AnimatePresence> <div
{isOpen && ( className={`fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden transition-all duration-200 ${
<motion.div isOpen
initial={{ opacity: 0, y: 10, scale: 0.98 }} ? "opacity-100 translate-y-0 pointer-events-auto"
animate={{ opacity: 1, y: 0, scale: 1 }} : "opacity-0 -translate-y-2 pointer-events-none"
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">
<div className="flex flex-col gap-3"> {navItems.map((item) => (
{navItems.map((item) => ( <Link
<Link key={item.name}
key={item.name} href={item.href}
href={item.href} onClick={() => setIsOpen(false)}
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"
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}
{item.name} </Link>
</Link> ))}
))} </div>
</div> </div>
</motion.div>
)}
</AnimatePresence>
</> </>
); );
}; };

View File

@@ -1,13 +1,22 @@
"use client"; "use client";
import { useState, useEffect } from "react"; 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 { SiGithub, SiLinkedin } from "react-icons/si";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import type { NavTranslations } from "@/types/translations"; 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 { interface HeaderClientProps {
locale: string; locale: string;
translations: NavTranslations; translations: NavTranslations;
@@ -44,7 +53,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
href: "https://linkedin.com/in/dkonkol", href: "https://linkedin.com/in/dkonkol",
label: "LinkedIn", 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}`), "") || ""; const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
@@ -55,53 +64,38 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
return ( return (
<> <>
<motion.header <header className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none">
initial={false}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
>
<div <div
className={`pointer-events-auto transition-all duration-500 ease-out ${ className={`pointer-events-auto transition-all duration-500 ease-out ${
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl" scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
}`} }`}
> >
<motion.div <div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className={`backdrop-blur-xl transition-all duration-500 flex justify-between items-center ${ className={`backdrop-blur-xl transition-all duration-500 flex justify-between items-center ${
scrolled 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/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" : "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 <div className="flex items-center space-x-2 hover:scale-105 transition-transform">
whileHover={{ scale: 1.05 }}
className="flex items-center space-x-2"
>
<Link <Link
href={`/${locale}`} href={`/${locale}`}
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center" className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
> >
dk<span className="text-red-500">0</span> dk<span className="text-red-500">0</span>
</Link> </Link>
</motion.div> </div>
<nav className="hidden md:flex items-center space-x-8"> <nav className="hidden md:flex items-center space-x-8">
{navItems.map((item) => ( {navItems.map((item) => (
<motion.div <div key={item.name} className="hover:-translate-y-0.5 active:scale-95 transition-all">
key={item.name}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.95 }}
>
<Link <Link
href={item.href} href={item.href}
className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover" className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
> >
{item.name} {item.name}
</Link> </Link>
</motion.div> </div>
))} ))}
{/* Language Switcher */} {/* Language Switcher */}
@@ -129,121 +123,109 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
</div> </div>
</nav> </nav>
<motion.button <button
whileHover={{ scale: 1.05, rotate: 90 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsOpen(!isOpen)} 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" aria-label="Toggle menu"
> >
{isOpen ? <X size={24} /> : <Menu size={24} />} {isOpen ? <XIcon size={24} /> : <MenuIcon size={24} />}
</motion.button> </button>
</motion.div> </div>
</div> </div>
</motion.header> </header>
<AnimatePresence> {/* Mobile menu overlay */}
{isOpen && ( <div
<motion.div className={`fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden transition-opacity duration-200 ${
initial={{ opacity: 0 }} isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
animate={{ opacity: 1 }} }`}
exit={{ opacity: 0 }} onClick={() => setIsOpen(false)}
transition={{ duration: 0.2 }} />
className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
onClick={() => setIsOpen(false)}
/>
)}
</AnimatePresence>
<AnimatePresence> {/* Mobile menu panel */}
{isOpen && ( <div
<motion.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 ${
initial={{ x: "100%", opacity: 0 }} isOpen ? "translate-x-0" : "translate-x-full"
animate={{ x: 0, opacity: 1 }} }`}
exit={{ x: "100%", opacity: 0 }} >
transition={{ type: "spring", damping: 30, stiffness: 300 }} <div className="p-6">
className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto" <div className="flex justify-between items-center mb-8">
> <Link
<div className="p-6"> href={`/${locale}`}
<div className="flex justify-between items-center mb-8"> className="text-2xl font-black text-stone-900"
<Link onClick={() => setIsOpen(false)}
href={`/${locale}`} >
className="text-2xl font-black text-stone-900" dk<span className="text-red-500">0</span>
onClick={() => setIsOpen(false)} </Link>
> <button
dk<span className="text-red-500">0</span> onClick={() => setIsOpen(false)}
</Link> className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
<button aria-label="Close menu"
onClick={() => setIsOpen(false)} >
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700" <XIcon size={24} />
aria-label="Close menu" </button>
> </div>
<X size={24} />
</button>
</div>
<nav className="space-y-2"> <nav className="space-y-2">
{navItems.map((item) => ( {navItems.map((item) => (
<Link <Link
key={item.name} key={item.name}
href={item.href} href={item.href}
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="block px-4 py-3 text-stone-700 hover:bg-stone-50 rounded-lg font-medium transition-colors" 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} <Icon size={20} />
</Link> </a>
))} );
</nav> })}
{/* Language Switcher Mobile */}
<div className="flex gap-2 mt-6 pt-6 border-t border-stone-200">
<Link
href={enHref}
onClick={() => setIsOpen(false)}
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
locale === "en"
? "bg-stone-900 text-white"
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
}`}
>
EN
</Link>
<Link
href={deHref}
onClick={() => setIsOpen(false)}
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
locale === "de"
? "bg-stone-900 text-white"
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
}`}
>
DE
</Link>
</div>
<div className="mt-8 pt-6 border-t border-stone-200">
<div className="flex justify-center space-x-6">
{socialLinks.map((link) => {
const Icon = link.icon;
return (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-all"
aria-label={link.label}
>
<Icon size={20} />
</a>
);
})}
</div>
</div>
</div> </div>
</motion.div> </div>
)} </div>
</AnimatePresence> </div>
</> </>
); );
} }

View File

@@ -1,140 +1,73 @@
"use client"; import { getTranslations } from "next-intl/server";
import { motion } from "framer-motion";
import { useLocale, useTranslations } from "next-intl";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react";
const Hero = () => { interface HeroProps {
const locale = useLocale(); locale: string;
const t = useTranslations("home.hero"); }
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
useEffect(() => { export default async function Hero({ locale: _locale }: HeroProps) {
(async () => { const t = await getTranslations("home.hero");
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;
return ( 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"> <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 */} {/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
<div className="absolute inset-0 pointer-events-none"> <div className="absolute inset-0 pointer-events-none overflow-hidden">
<motion.div <div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }} <div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
/>
<motion.div
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
/>
</div> </div>
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20"> <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 lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24"> <div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
{/* Left: Text Content */} {/* Left: Text Content */}
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10"> <div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
<motion.div <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]">
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"
>
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" /> <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> <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>
</motion.div> </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"> <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 <span className="block">
initial={{ opacity: 0, x: -50 }} {t("line1")}
animate={{ opacity: 1, x: 0 }} </span>
transition={{ duration: 0.8, delay: 0.1 }} <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">
className="block" {t("line2")}
> </span>
{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>
</h1> </h1>
<motion.p <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">
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"
>
{t("description")} {t("description")}
</motion.p> </p>
<motion.div <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]">
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"
>
<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"> <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" /> <div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
{t("ctaWork")} {t("ctaWork")}
</a> </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")} {t("ctaContact")}
</a> </a>
</motion.div> </div>
</div> </div>
{/* Right: The Photo */} {/* Right: The Photo */}
<motion.div <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]">
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="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="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)]"> <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>
<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"> <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> <span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
</div> </div>
</motion.div> </div>
</div> </div>
</div> </div>
<motion.div <div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4 animate-bounce">
animate={{ y: [0, 15, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4"
>
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" /> <div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
</motion.div> </div>
</section> </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 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> <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"> <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> </h2>
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light"> <p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
Projects that pushed my boundaries. Projects that pushed my boundaries.
@@ -74,13 +74,14 @@ const Projects = () => {
</div> </div>
</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) => ( projects.map((project) => (
<motion.div <motion.div
key={project.id} key={project.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="group relative" className="group relative"
> >
<Link href={`/${locale}/projects/${project.slug}`} className="block"> <Link href={`/${locale}/projects/${project.slug}`} className="block">

View File

@@ -101,7 +101,12 @@ const ReadBooks = () => {
} }
if (reviews.length === 0) { 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); const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,38 @@
"use client"; "use client";
import * as React from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({ type Theme = "light" | "dark";
children,
...props const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
}: React.ComponentProps<typeof NextThemesProvider>) { theme: "light",
return <NextThemesProvider {...props}>{children}</NextThemesProvider>; 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 * as React from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "./ThemeProvider";
import { motion } from "framer-motion";
export function ThemeToggle() { export function ThemeToggle() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -18,11 +17,9 @@ export function ThemeToggle() {
} }
return ( return (
<motion.button <button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} 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" aria-label="Toggle theme"
> >
{theme === "dark" ? ( {theme === "dark" ? (
@@ -30,6 +27,6 @@ export function ThemeToggle() {
) : ( ) : (
<Moon size={18} className="text-stone-600" /> <Moon size={18} className="text-stone-600" />
)} )}
</motion.button> </button>
); );
} }

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react"; import { useEffect } from "react";
export default function GlobalError({ export default function GlobalError({
@@ -11,15 +10,9 @@ export default function GlobalError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
// Capture exception in Sentry if (process.env.NODE_ENV === "development") {
Sentry.captureException(error); console.error("Global Error:", 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);
}, [error]); }, [error]);
return ( return (

View File

@@ -2,19 +2,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @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 { :root {
/* Warm Brown & Off-White Palette */ /* Warm Brown & Off-White Palette */

View File

@@ -3,9 +3,9 @@ import { Metadata } from "next";
import { Inter, Playfair_Display } from "next/font/google"; import { Inter, Playfair_Display } from "next/font/google";
import React from "react"; import React from "react";
import ClientProviders from "./components/ClientProviders"; import ClientProviders from "./components/ClientProviders";
import ShaderGradientBackground from "./components/ShaderGradientBackground";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getBaseUrl } from "@/lib/seo"; import { getBaseUrl } from "@/lib/seo";
import ShaderGradientBackground from "./components/ShaderGradientBackground";
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
@@ -32,6 +32,8 @@ export default async function RootLayout({
<html lang={locale} suppressHydrationWarning> <html lang={locale} suppressHydrationWarning>
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
{/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
</head> </head>
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning> <body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
<div className="grain-overlay" aria-hidden="true" /> <div className="grain-overlay" aria-hidden="true" />

View File

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

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import { Lock, Loader2 } from 'lucide-react'; import { Lock, Loader2 } from 'lucide-react';
import ModernAdminDashboard from '@/components/ModernAdminDashboard'; import ModernAdminDashboard from '@/components/ModernAdminDashboard';
@@ -58,7 +57,7 @@ const AdminPage = () => {
// Check if user is locked out // Check if user is locked out
const checkLockout = useCallback(() => { const checkLockout = useCallback(() => {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
try { try {
const lockoutData = localStorage.getItem('admin_lockout'); const lockoutData = localStorage.getItem('admin_lockout');
if (lockoutData) { if (lockoutData) {
@@ -103,11 +102,11 @@ const AdminPage = () => {
try { try {
const sessionToken = sessionStorage.getItem('admin_session_token'); const sessionToken = sessionStorage.getItem('admin_session_token');
if (!sessionToken) { if (!sessionToken) {
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isAuthenticated: false, isAuthenticated: false,
showLogin: true, showLogin: true,
isLoading: false isLoading: false
})); }));
return; return;
} }
@@ -118,38 +117,38 @@ const AdminPage = () => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': authState.csrfToken 'X-CSRF-Token': authState.csrfToken
}, },
body: JSON.stringify({ body: JSON.stringify({
sessionToken, sessionToken,
csrfToken: authState.csrfToken csrfToken: authState.csrfToken
}) })
}); });
const data = await response.json(); const data = await response.json();
if (response.ok && data.valid) { if (response.ok && data.valid) {
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isAuthenticated: true, isAuthenticated: true,
showLogin: false, showLogin: false,
isLoading: false isLoading: false
})); }));
sessionStorage.setItem('admin_authenticated', 'true'); sessionStorage.setItem('admin_authenticated', 'true');
} else { } else {
sessionStorage.removeItem('admin_authenticated'); sessionStorage.removeItem('admin_authenticated');
sessionStorage.removeItem('admin_session_token'); sessionStorage.removeItem('admin_session_token');
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isAuthenticated: false, isAuthenticated: false,
showLogin: true, showLogin: true,
isLoading: false isLoading: false
})); }));
} }
} catch { } catch {
setAuthState(prev => ({ setAuthState(prev => ({
...prev, ...prev,
isAuthenticated: false, isAuthenticated: false,
showLogin: true, showLogin: true,
isLoading: false isLoading: false
})); }));
} }
}, [authState.csrfToken]); }, [authState.csrfToken]);
@@ -158,13 +157,13 @@ const AdminPage = () => {
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
if (checkLockout()) return; if (checkLockout()) return;
const token = await fetchCSRFToken(); const token = await fetchCSRFToken();
if (token) { if (token) {
setAuthState(prev => ({ ...prev, csrfToken: token })); setAuthState(prev => ({ ...prev, csrfToken: token }));
} }
}; };
init(); init();
}, [checkLockout, fetchCSRFToken]); }, [checkLockout, fetchCSRFToken]);
@@ -178,7 +177,7 @@ const AdminPage = () => {
// Handle login form submission // Handle login form submission
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!authState.password.trim() || authState.isLoading) return; if (!authState.password.trim() || authState.isLoading) return;
setAuthState(prev => ({ ...prev, isLoading: true, error: '' })); setAuthState(prev => ({ ...prev, isLoading: true, error: '' }));
@@ -259,10 +258,12 @@ const AdminPage = () => {
// Loading state // Loading state
if (authState.isLoading) { if (authState.isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]"> <div className="min-h-screen flex items-center justify-center bg-stone-50 dark:bg-stone-950">
<div className="text-center"> <div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-[#795548]" /> <div className="font-mono text-sm font-black tracking-tighter text-stone-900 dark:text-stone-50">
<p className="text-[#5d4037]">Loading...</p> dk<span className="text-red-500">0</span>.dev
</div>
<Loader2 className="w-5 h-5 animate-spin mx-auto text-emerald-500" />
</div> </div>
</div> </div>
); );
@@ -271,26 +272,38 @@ const AdminPage = () => {
// Lockout state // Lockout state
if (authState.isLocked) { if (authState.isLocked) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]"> <div className="min-h-screen flex items-center justify-center bg-stone-50 dark:bg-stone-950 px-6">
<div className="text-center"> <div className="w-full max-w-sm">
<div className="w-16 h-16 bg-[#fecaca] rounded-2xl flex items-center justify-center mx-auto mb-6"> <div className="bg-white dark:bg-stone-900 rounded-[2.5rem] border border-stone-200 dark:border-stone-800 overflow-hidden shadow-2xl">
<Lock className="w-8 h-8 text-[#d84315]" /> <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> </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>
</div> </div>
); );
@@ -299,70 +312,84 @@ const AdminPage = () => {
// Login form // Login form
if (authState.showLogin || !authState.isAuthenticated) { if (authState.showLogin || !authState.isAuthenticated) {
return ( return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#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 */}
<motion.div <div className="absolute inset-0 pointer-events-none overflow-hidden">
initial={{ opacity: 0, scale: 0.95 }} <div className="absolute top-[5%] left-[5%] w-[50vw] h-[50vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
animate={{ opacity: 1, scale: 1 }} <div className="absolute bottom-[5%] right-[5%] w-[40vw] h-[40vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
className="w-full max-w-md p-6" </div>
>
<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>
<form onSubmit={handleLogin} className="space-y-5"> <div className="relative z-10 w-full max-w-sm animate-[fadeIn_0.4s_ease-out]">
<div> <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="relative"> <div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
<input
type={authState.showPassword ? 'text' : 'password'} <div className="p-10">
value={authState.password} <div className="text-center mb-8">
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))} <p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-5">
placeholder="Enter password" dk<span className="text-red-500">0</span>.dev · admin
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" </p>
disabled={authState.isLoading} <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" />
<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> </div>
{authState.error && ( <h1 className="text-3xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 mb-1">
<motion.p Admin Access
initial={{ opacity: 0, y: -5 }} </h1>
animate={{ opacity: 1, y: 0 }} <p className="text-stone-500 dark:text-stone-400 text-sm">
className="mt-2 text-[#d84315] text-sm font-medium flex items-center" Enter your password to continue
> </p>
<span className="w-1.5 h-1.5 bg-[#d84315] rounded-full mr-2" />
{authState.error}
</motion.p>
)}
</div> </div>
<button <form onSubmit={handleLogin} className="space-y-4">
type="submit" <div>
disabled={authState.isLoading || !authState.password} <div className="relative">
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" <input
> type={authState.showPassword ? 'text' : 'password'}
{authState.isLoading ? ( value={authState.password}
<div className="flex items-center justify-center space-x-2"> onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
<Loader2 className="w-5 h-5 animate-spin" /> placeholder="Password"
<span className="text-[#faf8f3]">Authenticating...</span> 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> </div>
) : ( {authState.error && (
<span className="text-[#faf8f3]">Sign In</span> <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" />
</button> {authState.error}
</form> </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> </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> </div>
); );
} }
@@ -375,4 +402,4 @@ const AdminPage = () => {
); );
}; };
export default AdminPage; export default AdminPage;

View File

@@ -1,20 +1,18 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { motion } from 'framer-motion';
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react'; import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
import Header from "../components/Header"; import Header from "../components/Header";
import Footer from "../components/Footer"; import Footer from "../components/Footer";
import Link from "next/link"; import Link from "next/link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { JSONContent } from "@tiptap/react";
import RichTextClient from "../components/RichTextClient"; import RichTextClient from "../components/RichTextClient";
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("common"); const t = useTranslations("common");
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null); const [cmsHtml, setCmsHtml] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -23,8 +21,8 @@ export default function PrivacyPolicy() {
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`, `/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
); );
const data = await res.json(); const data = await res.json();
if (data?.content?.content && data?.content?.locale === locale) { if (data?.content?.html && data?.content?.locale === locale) {
setCmsDoc(data.content.content as JSONContent); setCmsHtml(data.content.html as string);
} }
} catch {} } catch {}
})(); })();
@@ -34,13 +32,9 @@ export default function PrivacyPolicy() {
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"> <div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
<Header /> <Header />
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20"> <main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
{/* Editorial Header */} {/* Editorial Header */}
<motion.div <div className="animate-fade-in mb-20">
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
className="mb-20"
>
<Link <Link
href={`/${locale}`} 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" className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
@@ -48,25 +42,20 @@ export default function PrivacyPolicy() {
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" /> <ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span> <span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
</Link> </Link>
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase"> <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> Privacy<span className="text-liquid-purple">.</span>
</h1> </h1>
</motion.div> </div>
{/* Bento Content Grid */} {/* Bento Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Main Privacy Text (Large) */} {/* Main Privacy Text (Large) */}
<motion.div <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">
initial={{ opacity: 0, y: 30 }} {cmsHtml ? (
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
>
{cmsDoc ? (
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed"> <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>
) : ( ) : (
<div className="space-y-16"> <div className="space-y-16">
@@ -89,11 +78,11 @@ export default function PrivacyPolicy() {
</section> </section>
</div> </div>
)} )}
</motion.div> </div>
{/* Quick Info Cards */} {/* Quick Info Cards */}
<div className="lg:col-span-4 space-y-8"> <div className="lg:col-span-4 space-y-8">
{/* Core Values Box */} {/* Core Values Box */}
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white"> <div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-purple">Principles</h3> <h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-purple">Principles</h3>
@@ -129,7 +118,7 @@ export default function PrivacyPolicy() {
<p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p> <p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p>
</div> </div>
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p> <p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
<button <button
onClick={() => { onClick={() => {
localStorage.removeItem('cookie-consent'); localStorage.removeItem('cookie-consent');
window.location.reload(); window.location.reload();

View File

@@ -2,6 +2,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from 'lucide-react'; import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
@@ -41,25 +42,6 @@ const ProjectDetail = () => {
const loadedProject = data.projects[0]; const loadedProject = data.projects[0];
setProject(loadedProject); setProject(loadedProject);
// Track page view
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'pageview',
projectId: loadedProject.id.toString(),
page: `/projects/${slug}`
})
});
} catch (trackError) {
// Silently fail tracking
if (process.env.NODE_ENV === 'development') {
console.error('Error tracking page view:', trackError);
}
}
} }
} }
} catch (error) { } catch (error) {
@@ -151,9 +133,11 @@ const ProjectDetail = () => {
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative" className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
> >
{project.imageUrl ? ( {project.imageUrl ? (
<img <Image
src={project.imageUrl} src={project.imageUrl}
alt={project.title} alt={project.title}
fill
unoptimized
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from 'lucide-react'; import { ExternalLink, Github, Calendar, ArrowLeft, Search } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
@@ -159,9 +160,11 @@ const ProjectsPage = () => {
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100"> <div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
{project.imageUrl ? ( {project.imageUrl ? (
<> <>
<img <Image
src={project.imageUrl} src={project.imageUrl}
alt={project.title} alt={project.title}
fill
unoptimized
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110" className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" /> <div className="absolute inset-0 bg-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,321 +0,0 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useWebVitals } from '@/lib/useWebVitals';
import { trackEvent, trackPageLoad } from '@/lib/analytics';
import { debounce } from '@/lib/utils';
interface AnalyticsProviderProps {
children: React.ReactNode;
}
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
const hasTrackedInitialView = useRef(false);
const hasTrackedPerformance = useRef(false);
const currentPath = useRef('');
// Initialize Web Vitals tracking - wrapped to prevent crashes
// Hooks must be called unconditionally, but the hook itself handles errors
useWebVitals();
// Track page view - memoized to prevent recreation
const trackPageView = useCallback(async () => {
if (typeof window === 'undefined') return;
const path = window.location.pathname;
// Only track if path has changed (prevents duplicate tracking)
if (currentPath.current === path && hasTrackedInitialView.current) {
return;
}
currentPath.current = path;
hasTrackedInitialView.current = true;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null;
// Track to Umami (if available)
trackEvent('page-view', {
url: path,
referrer: document.referrer,
timestamp: Date.now(),
});
// Track to our API - single call
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'pageview',
projectId: projectId,
page: path
})
});
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.error('Error tracking page view:', error);
}
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
// Wrap entire effect in try-catch to prevent any errors from breaking the app
try {
// Track page load performance - wrapped in try-catch
try {
trackPageLoad();
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking page load:', error);
}
}
// Track initial page view
trackPageView();
// Track performance metrics to our API - only once
const trackPerformanceToAPI = async () => {
// Prevent duplicate tracking
if (hasTrackedPerformance.current) return;
hasTrackedPerformance.current = true;
try {
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
return;
}
// Get current page path to extract project ID if on project page
const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null;
// Wait for page to fully load
setTimeout(async () => {
try {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
const paintEntries = performance.getEntriesByType('paint');
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
const performanceData = {
loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
fcp: fcp ? fcp.startTime : 0,
lcp: lcp ? lcp.startTime : 0,
ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
cls: 0, // Will be updated by CLS observer
fid: 0, // Will be updated by FID observer
si: 0 // Speed Index - would need to calculate
};
// Send performance data - single call
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'performance',
projectId: projectId,
page: path,
performance: performanceData
})
});
} catch (error) {
// Silently fail - performance tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error collecting performance data:', error);
}
}
}, 2500); // Wait 2.5 seconds for page to stabilize
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.error('Error tracking performance:', error);
}
}
};
// Track performance after page load
if (document.readyState === 'complete') {
trackPerformanceToAPI();
} else {
window.addEventListener('load', trackPerformanceToAPI, { once: true });
}
// Track route changes (for SPA navigation) - debounced
const handleRouteChange = debounce(() => {
// Track new page view (trackPageView will handle path change detection)
trackPageView();
trackPageLoad();
}, 300);
// Listen for popstate events (back/forward navigation)
window.addEventListener('popstate', handleRouteChange);
// Track user interactions - debounced to prevent spam
const handleClick = debounce((event: unknown) => {
try {
if (typeof window === 'undefined') return;
const mouseEvent = event as MouseEvent;
const target = mouseEvent.target as HTMLElement | null;
if (!target) return;
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
const className = target.className;
const id = target.id;
trackEvent('click', {
element,
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined,
id: id || undefined,
url: window.location.pathname,
});
} catch (error) {
// Silently fail - click tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking click:', error);
}
}
}, 500);
// Track form submissions
const handleSubmit = (event: SubmitEvent) => {
try {
if (typeof window === 'undefined') return;
const form = event.target as HTMLFormElement | null;
if (!form) return;
trackEvent('form-submit', {
formId: form.id || undefined,
formClass: form.className || undefined,
url: window.location.pathname,
});
} catch (error) {
// Silently fail - form tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking form submit:', error);
}
}
};
// Track scroll depth - debounced
let maxScrollDepth = 0;
const firedScrollMilestones = new Set<number>();
const handleScroll = debounce(() => {
try {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
const scrollHeight = document.documentElement.scrollHeight;
const innerHeight = window.innerHeight;
if (scrollHeight <= innerHeight) return; // No scrollable content
const scrollDepth = Math.round(
(window.scrollY / (scrollHeight - innerHeight)) * 100
);
if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
// Track each milestone once (avoid spamming events on every scroll tick)
const milestones = [25, 50, 75, 90];
for (const milestone of milestones) {
if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
firedScrollMilestones.add(milestone);
trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
}
}
} catch (error) {
// Silently fail - scroll tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking scroll:', error);
}
}
}, 1000);
// Add event listeners
document.addEventListener('click', handleClick);
document.addEventListener('submit', handleSubmit);
window.addEventListener('scroll', handleScroll, { passive: true });
// Track errors
const handleError = (event: ErrorEvent) => {
try {
if (typeof window === 'undefined') return;
trackEvent('error', {
message: event.message || 'Unknown error',
filename: event.filename || undefined,
lineno: event.lineno || undefined,
colno: event.colno || undefined,
url: window.location.pathname,
});
} catch (error) {
// Silently fail - error tracking should not cause more errors
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking error event:', error);
}
}
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
try {
if (typeof window === 'undefined') return;
trackEvent('unhandled-rejection', {
reason: event.reason?.toString() || 'Unknown rejection',
url: window.location.pathname,
});
} catch (error) {
// Silently fail - error tracking should not cause more errors
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking unhandled rejection:', error);
}
}
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
// Cleanup
return () => {
try {
// Cancel any pending debounced calls to prevent memory leaks
handleRouteChange.cancel();
handleClick.cancel();
handleScroll.cancel();
// Remove event listeners
window.removeEventListener('load', trackPerformanceToAPI);
window.removeEventListener('popstate', handleRouteChange);
document.removeEventListener('click', handleClick);
document.removeEventListener('submit', handleSubmit);
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
} catch {
// Silently fail during cleanup
}
};
} catch (error) {
// If anything fails, log but don't break the app
if (process.env.NODE_ENV === 'development') {
console.error('AnalyticsProvider initialization error:', error);
}
// Return empty cleanup function
return () => {};
}
}, [trackPageView]);
// Always render children, even if analytics fails
return <>{children}</>;
};

View File

@@ -1,169 +1,45 @@
"use client"; "use client";
import { motion, useMotionValue, useTransform, useSpring } from "framer-motion"; import { motion } from "framer-motion";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const BackgroundBlobs = () => { 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); const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); 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; if (!mounted) return null;
return ( return (
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0"> <div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
{/* Mint blob - top left */} {/* Mint blob - top left */}
<motion.div <motion.div
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[100px] mix-blend-multiply" className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
style={{ x: x1, y: y1 }} animate={{ scale: [1, 1.15, 1] }}
animate={{ transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
scale: [1, 1.15, 1],
rotate: [0, 45, 0],
}}
transition={{
duration: 40,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/> />
{/* Lavender blob - top right */} {/* Lavender blob - top right */}
<motion.div <motion.div
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[100px] mix-blend-multiply" className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
style={{ x: x2, y: y2 }} animate={{ scale: [1, 1.1, 1] }}
animate={{ transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
scale: [1, 1.1, 1],
rotate: [0, -30, 0],
}}
transition={{
duration: 45,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/> />
{/* Rose blob - bottom left */} {/* Rose blob - bottom left */}
<motion.div <motion.div
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[100px] mix-blend-multiply" className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
style={{ x: x3, y: y3 }} animate={{ scale: [1, 1.2, 1] }}
animate={{ transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
scale: [1, 1.2, 1],
rotate: [0, 60, 0],
}}
transition={{
duration: 50,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
/> />
{/* Peach blob - middle right */} {/* Peach blob - middle right */}
<motion.div <motion.div
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[120px] mix-blend-multiply" className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
style={{ x: x4, y: y4 }} animate={{ scale: [1, 1.25, 1] }}
animate={{ transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
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",
}}
/> />
</div> </div>
); );

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Mail, Mail,
Settings, Settings,
@@ -21,23 +20,23 @@ import dynamic from 'next/dynamic';
const EmailManager = dynamic( const EmailManager = dynamic(
() => import('./EmailManager').then((m) => m.EmailManager), () => 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( const AnalyticsDashboard = dynamic(
() => import('./AnalyticsDashboard').then((m) => m.default), () => 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( const ImportExport = dynamic(
() => import('./ImportExport').then((m) => m.default), () => 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( const ProjectManager = dynamic(
() => import('./ProjectManager').then((m) => m.ProjectManager), () => 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( const ContentManager = dynamic(
() => import('./ContentManager').then((m) => m.default), () => 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 { interface Project {
@@ -69,8 +68,10 @@ interface ModernAdminDashboardProps {
isAuthenticated?: boolean; isAuthenticated?: boolean;
} }
type TabId = 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings';
const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthenticated = true }) => { 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[]>([]); const [projects, setProjects] = useState<Project[]>([]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const [isLoading, setIsLoading] = useState(false); 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), 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, unreadEmails: emails.filter(e => !(e.read as boolean)).length,
avgPerformance: (() => { avgPerformance: (() => {
// Only show real performance data, not defaults
const projectsWithPerf = projects.filter(p => { const projectsWithPerf = projects.filter(p => {
const perf = p.performance as Record<string, unknown> || {}; const perf = p.performance as Record<string, unknown> || {};
return (perf.lighthouse as number || 0) > 0; return (perf.lighthouse as number || 0) > 0;
@@ -198,7 +198,6 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
}; };
useEffect(() => { useEffect(() => {
// Prioritize the data needed for the initial dashboard render
void (async () => { void (async () => {
await Promise.all([loadProjects(), loadSystemStats()]); await Promise.all([loadProjects(), loadSystemStats()]);
@@ -218,467 +217,424 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
}, [loadProjects, loadSystemStats, loadAnalytics, loadEmails]); }, [loadProjects, loadSystemStats, loadAnalytics, loadEmails]);
const navigation = [ const navigation = [
{ id: 'overview', label: 'Dashboard', icon: Home, color: 'blue', description: 'Overview & Statistics' }, { id: 'overview' as TabId, label: 'Dashboard', icon: Home, description: 'Overview & Statistics' },
{ id: 'projects', label: 'Projects', icon: Database, color: 'green', description: 'Manage Projects' }, { id: 'projects' as TabId, label: 'Projects', icon: Database, description: 'Manage Projects' },
{ id: 'emails', label: 'Emails', icon: Mail, color: 'purple', description: 'Email Management' }, { id: 'emails' as TabId, label: 'Emails', icon: Mail, description: 'Email Management' },
{ id: 'analytics', label: 'Analytics', icon: Activity, color: 'orange', description: 'Site Analytics' }, { id: 'analytics' as TabId, label: 'Analytics', icon: Activity, description: 'Site Analytics' },
{ id: 'content', label: 'Content', icon: Shield, color: 'teal', description: 'Texts, pages & localization' }, { id: 'content' as TabId, label: 'Content', icon: Shield, description: 'Texts, pages & localization' },
{ id: 'settings', label: 'Settings', icon: Settings, color: 'gray', description: 'System Settings' } { 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 ( return (
<div className="min-h-screen"> <div className="min-h-screen bg-stone-50 dark:bg-stone-950">
{/* Animated Background - same as main site */}
<div className="fixed inset-0 animated-bg"></div>
{/* Admin Navbar - Horizontal Navigation */} {/* Navbar */}
<div className="relative z-10"> <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">
<div className="admin-glass border-b border-white/20 sticky top-0"> {/* Gradient accent bar */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
<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>
{/* Center - Desktop Navigation */} <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="hidden md:flex items-center space-x-1"> <div className="flex items-center justify-between h-14">
{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>
{/* Right side - User info and Logout */} {/* Left: branding */}
<div className="flex items-center space-x-4"> <div className="flex items-center gap-4">
<div className="hidden sm:block text-sm text-stone-500"> <Link
Welcome, <span className="text-stone-800 font-semibold">Dennis</span> href="/"
</div> 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 <button
onClick={async () => { key={item.id}
try { onClick={() => setActiveTab(item.id)}
await fetch('/api/auth/logout', { method: 'POST' }); 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 ${
sessionStorage.removeItem('admin_authenticated'); activeTab === item.id
sessionStorage.removeItem('admin_session_token'); ? 'bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900'
window.location.href = '/manage'; : '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'
} 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"
> >
<LogOut size={16} /> <item.icon size={13} />
<span className="hidden sm:inline text-sm font-medium">Logout</span> {item.label}
</button> </button>
))}
</div>
{/* Mobile menu button */} {/* Right: user + logout + mobile toggle */}
<button <div className="flex items-center gap-3">
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} <span className="hidden sm:block text-xs text-stone-400 dark:text-stone-500 font-mono">
className="md:hidden flex items-center justify-center p-2 rounded-lg text-stone-600 hover:bg-stone-100 transition-colors" Dennis
> </span>
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />} <button
</button> onClick={async () => {
</div> 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> </div>
</div>
{/* Mobile Navigation Menu */} {/* Mobile menu */}
<AnimatePresence> {mobileMenuOpen && (
{mobileMenuOpen && ( <div className="md:hidden border-t border-stone-200 dark:border-stone-800 bg-stone-50 dark:bg-stone-950">
<motion.div <div className="px-4 py-3 grid grid-cols-2 gap-2">
initial={{ opacity: 0, height: 0 }} {navigation.map((item) => (
animate={{ opacity: 1, height: 'auto' }} <button
exit={{ opacity: 0, height: 0 }} key={item.id}
className="md:hidden border-t border-stone-200 bg-white" onClick={() => {
> setActiveTab(item.id);
<div className="px-4 py-4 space-y-2"> setMobileMenuOpen(false);
{navigation.map((item) => ( }}
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 <button
key={item.id} key={item.label}
onClick={() => { onClick={item.action}
setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings'); 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`}
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'
}`}
> >
<item.icon size={18} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} /> <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">
<div className="text-left"> <item.icon size={15} className="text-stone-600 dark:text-stone-300" />
<div className="font-medium text-sm">{item.label}</div> </div>
<div className="text-xs opacity-70">{item.description}</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> </div>
</button> </button>
))} ))}
</div> </div>
</motion.div> </div>
)} </div>
</AnimatePresence> </div>
</div> )}
{/* Main Content - Full Width Horizontal Layout */} {activeTab === 'projects' && (
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-6 lg:py-8"> <div className="space-y-6">
{/* Content */} <div>
<AnimatePresence mode="wait"> <h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
<motion.div Projects<span className="text-emerald-500">.</span>
key={activeTab} </h1>
initial={{ opacity: 0, y: 20 }} <p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">Manage your portfolio projects</p>
animate={{ opacity: 1, y: 0 }} </div>
exit={{ opacity: 0, y: -20 }} <ProjectManager projects={projects} onProjectsChange={loadProjects} />
transition={{ duration: 0.3 }} </div>
> )}
{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>
{/* Stats Grid - Mobile: 2x3, Desktop: 6x1 horizontal */} {activeTab === 'emails' && (
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 md:gap-6"> <EmailManager />
<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')} {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
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"> <span className="text-stone-600 dark:text-stone-300 font-medium text-sm">{item.label}</span>
<div className="flex items-center justify-between"> <div className="flex items-center gap-2">
<p className="text-stone-500 text-xs md:text-sm font-medium">Projects</p> <div className={`w-2 h-2 ${item.dot} rounded-full animate-pulse`} />
<Database size={20} className="text-stone-400" /> <span className={`${item.text} font-bold text-sm`}>Online</span>
</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>
</div> </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">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>
</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>
</div> </div>
); );
}; };
export default ModernAdminDashboard; export default ModernAdminDashboard;

View File

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

View File

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

View File

@@ -1,32 +1,2 @@
// This file configures the initialization of Sentry on the client. // Client-side instrumentation hook for Next.js
// The added config here will be used whenever a users loads a page in their browser. // Add any client-side instrumentation here if needed
// 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;

View File

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

View File

@@ -1,144 +0,0 @@
// Analytics utilities for Umami with Performance Tracking
declare global {
interface Window {
umami?: {
track: (event: string, data?: Record<string, unknown>) => void;
};
}
}
export interface PerformanceMetric {
name: string;
value: number;
url: string;
timestamp: number;
userAgent?: string;
}
export interface WebVitalsMetric {
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
value: number;
delta: number;
id: string;
url: string;
}
// Track custom events to Umami
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
if (typeof window === "undefined") return;
const trackFn = window.umami?.track;
if (typeof trackFn !== "function") return;
try {
trackFn(event, {
...data,
timestamp: Date.now(),
url: window.location.pathname,
});
} catch (error) {
// Silently fail - analytics must never break the app
if (process.env.NODE_ENV === "development") {
console.warn("Error tracking Umami event:", error);
}
}
};
// Track performance metrics
export const trackPerformance = (metric: PerformanceMetric) => {
trackEvent('performance', {
metric: metric.name,
value: Math.round(metric.value),
url: metric.url,
userAgent: metric.userAgent,
});
};
// Track Web Vitals
export const trackWebVitals = (metric: WebVitalsMetric) => {
trackEvent('web-vitals', {
name: metric.name,
value: Math.round(metric.value),
delta: Math.round(metric.delta),
id: metric.id,
url: metric.url,
});
};
// Track page load performance
export const trackPageLoad = () => {
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
try {
const navigationEntries = performance.getEntriesByType('navigation');
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
trackPerformance({
name: 'page-load',
value: navigation.loadEventEnd - navigation.fetchStart,
url: window.location.pathname,
timestamp: Date.now(),
userAgent: navigator.userAgent,
});
// Track individual timing phases
trackEvent('page-timing', {
dns: navigation.domainLookupEnd && navigation.domainLookupStart
? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
: 0,
tcp: navigation.connectEnd && navigation.connectStart
? Math.round(navigation.connectEnd - navigation.connectStart)
: 0,
request: navigation.responseStart && navigation.requestStart
? Math.round(navigation.responseStart - navigation.requestStart)
: 0,
response: navigation.responseEnd && navigation.responseStart
? Math.round(navigation.responseEnd - navigation.responseStart)
: 0,
dom: navigation.domContentLoadedEventEnd && navigation.responseEnd
? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd)
: 0,
load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd
? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd)
: 0,
url: window.location.pathname,
});
}
} catch (error) {
// Silently fail - performance tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('Error tracking page load:', error);
}
}
};
// Track API response times
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
if (typeof window === 'undefined') return;
trackEvent('api-call', {
endpoint,
duration: Math.round(duration),
status,
url: window.location.pathname,
});
};
// Track user interactions
export const trackInteraction = (action: string, element?: string) => {
if (typeof window === 'undefined') return;
trackEvent('interaction', {
action,
element,
url: window.location.pathname,
});
};
// Track errors
export const trackError = (error: string, context?: string) => {
if (typeof window === 'undefined') return;
trackEvent('error', {
error,
context,
url: window.location.pathname,
});
};

View File

@@ -196,9 +196,9 @@ if (typeof window === 'undefined') {
}, 60000); // Clear every minute }, 60000); // Clear every minute
} }
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean { export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean {
const now = Date.now(); const now = Date.now();
const key = `admin_${ip}`; const key = `${prefix}_${ip}`;
const current = rateLimitMap.get(key); const current = rateLimitMap.get(key);
@@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n
return true; return true;
} }
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> { export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record<string, string> {
const current = rateLimitMap.get(`admin_${ip}`); const current = rateLimitMap.get(`${prefix}_${ip}`);
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests; const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
return { return {

View File

@@ -993,3 +993,83 @@ export async function getSnippets(limit = 10, featured?: boolean): Promise<Snipp
return null; 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 * Get a localized message by key.
* Tries: Directus (requested locale) → Directus (EN) → JSON (requested locale) → JSON (EN) * 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( export async function getLocalizedMessage(
key: string, key: string,
@@ -40,31 +44,16 @@ export async function getLocalizedMessage(
const cached = getCached(cacheKey); const cached = getCached(cacheKey);
if (cached !== null) return cached as string; 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'; const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
// 1) JSON requested locale
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key); const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
if (jsonValue) { if (jsonValue) {
setCached(cacheKey, jsonValue); setCached(cacheKey, jsonValue);
return jsonValue; return jsonValue;
} }
// Fallback to EN JSON // 2) JSON EN fallback
if (normalizedLocale !== 'en') { if (normalizedLocale !== 'en') {
const jsonValueEn = getNestedValue(jsonFallback['en'], key); const jsonValueEn = getNestedValue(jsonFallback['en'], key);
if (jsonValueEn) { 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; return key;
} }

View File

@@ -1,355 +0,0 @@
'use client';
import { useEffect } from 'react';
import { trackWebVitals, trackPerformance } from './analytics';
// Web Vitals types
interface Metric {
name: string;
value: number;
delta: number;
id: string;
}
// Simple Web Vitals implementation (since we don't want to add external dependencies)
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
let clsValue = 0;
let sessionValue = 0;
let sessionEntries: PerformanceEntry[] = [];
const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) {
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
sessionEntries.push(entry);
} else {
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
sessionEntries = [entry];
}
if (sessionValue > clsValue) {
clsValue = sessionValue;
onPerfEntry({
name: 'CLS',
value: clsValue,
delta: clsValue,
id: `cls-${Date.now()}`,
});
}
}
}
} catch (error) {
// Silently fail - CLS tracking is not critical
if (process.env.NODE_ENV === 'development') {
console.warn('CLS tracking error:', error);
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
return observer;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('CLS observer initialization failed:', error);
}
return null;
}
};
const getFID = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) {
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
if (processingStart !== undefined) {
onPerfEntry({
name: 'FID',
value: processingStart - entry.startTime,
delta: processingStart - entry.startTime,
id: `fid-${Date.now()}`,
});
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('FID tracking error:', error);
}
}
});
observer.observe({ type: 'first-input', buffered: true });
return observer;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('FID observer initialization failed:', error);
}
return null;
}
};
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
onPerfEntry({
name: 'FCP',
value: entry.startTime,
delta: entry.startTime,
id: `fcp-${Date.now()}`,
});
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('FCP tracking error:', error);
}
}
});
observer.observe({ type: 'paint', buffered: true });
return observer;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('FCP observer initialization failed:', error);
}
return null;
}
};
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => {
try {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
onPerfEntry({
name: 'LCP',
value: lastEntry.startTime,
delta: lastEntry.startTime,
id: `lcp-${Date.now()}`,
});
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('LCP tracking error:', error);
}
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
return observer;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('LCP observer initialization failed:', error);
}
return null;
}
};
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
try {
const observer = new PerformanceObserver((list) => {
try {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
const navEntry = entry as PerformanceNavigationTiming;
if (navEntry.responseStart && navEntry.fetchStart) {
onPerfEntry({
name: 'TTFB',
value: navEntry.responseStart - navEntry.fetchStart,
delta: navEntry.responseStart - navEntry.fetchStart,
id: `ttfb-${Date.now()}`,
});
}
}
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('TTFB tracking error:', error);
}
}
});
observer.observe({ type: 'navigation', buffered: true });
return observer;
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('TTFB observer initialization failed:', error);
}
return null;
}
};
// Custom hook for Web Vitals tracking
export const useWebVitals = () => {
useEffect(() => {
if (typeof window === 'undefined') return;
// Wrap everything in try-catch to prevent errors from breaking the app
try {
const safeNow = () => {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
};
// Store web vitals for batch sending
const webVitals: Record<string, number> = {};
const path = window.location.pathname;
const projectMatch = path.match(/\/projects\/([^\/]+)/);
const projectId = projectMatch ? projectMatch[1] : null;
const observers: PerformanceObserver[] = [];
const sendWebVitals = async () => {
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
try {
await fetch('/api/analytics/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'performance',
projectId: projectId,
page: path,
performance: {
fcp: webVitals.FCP || 0,
lcp: webVitals.LCP || 0,
cls: webVitals.CLS || 0,
fid: webVitals.FID || 0,
ttfb: webVitals.TTFB || 0,
loadTime: safeNow()
}
})
});
} catch (error) {
// Silently fail
if (process.env.NODE_ENV === 'development') {
console.error('Error sending web vitals:', error);
}
}
}
};
// Track Core Web Vitals
const clsObserver = getCLS((metric) => {
webVitals.CLS = metric.value;
trackWebVitals({
...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname,
});
sendWebVitals();
});
if (clsObserver) observers.push(clsObserver);
const fidObserver = getFID((metric) => {
webVitals.FID = metric.value;
trackWebVitals({
...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname,
});
sendWebVitals();
});
if (fidObserver) observers.push(fidObserver);
const fcpObserver = getFCP((metric) => {
webVitals.FCP = metric.value;
trackWebVitals({
...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname,
});
sendWebVitals();
});
if (fcpObserver) observers.push(fcpObserver);
const lcpObserver = getLCP((metric) => {
webVitals.LCP = metric.value;
trackWebVitals({
...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname,
});
sendWebVitals();
});
if (lcpObserver) observers.push(lcpObserver);
const ttfbObserver = getTTFB((metric) => {
webVitals.TTFB = metric.value;
trackWebVitals({
...metric,
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
url: window.location.pathname,
});
sendWebVitals();
});
if (ttfbObserver) observers.push(ttfbObserver);
// Track page load performance
const handleLoad = () => {
setTimeout(() => {
trackPerformance({
name: 'page-load-complete',
value: safeNow(),
url: window.location.pathname,
timestamp: Date.now(),
userAgent: navigator.userAgent,
});
}, 0);
};
if (document.readyState === 'complete') {
handleLoad();
} else {
window.addEventListener('load', handleLoad);
}
return () => {
// Cleanup all observers
observers.forEach(observer => {
try {
observer.disconnect();
} catch {
// Silently fail
}
});
try {
window.removeEventListener('load', handleLoad);
} catch {
// Silently fail
}
};
} catch (error) {
// If Web Vitals initialization fails, don't break the app
if (process.env.NODE_ENV === 'development') {
console.warn('Web Vitals initialization failed:', error);
}
// Return empty cleanup function
return () => {};
}
}, []);
};

View File

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

View File

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

View File

@@ -70,9 +70,7 @@ export function middleware(request: NextRequest) {
pathname.startsWith("/api/") || pathname.startsWith("/api/") ||
pathname === "/api" || pathname === "/api" ||
pathname.startsWith("/manage") || pathname.startsWith("/manage") ||
pathname.startsWith("/editor") || pathname.startsWith("/editor");
pathname === "/sentry-example-page" ||
pathname.startsWith("/sentry-example-page/");
// Locale routing for public site pages // Locale routing for public site pages
const responseUrl = request.nextUrl.clone(); const responseUrl = request.nextUrl.clone();
@@ -88,11 +86,15 @@ export function middleware(request: NextRequest) {
return addHeaders(request, res); 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 preferred = pickLocaleFromHeader(request.headers.get("accept-language"));
const redirectTarget = if (pathname === "/") {
pathname === "/" ? `/${preferred}` : `/${preferred}${pathname}${search || ""}`; responseUrl.pathname = `/${preferred}`;
responseUrl.pathname = redirectTarget; 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); const res = NextResponse.redirect(responseUrl);
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" }); res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
return addHeaders(request, res); return addHeaders(request, res);

View File

@@ -3,8 +3,6 @@ import dotenv from "dotenv";
import path from "path"; import path from "path";
import bundleAnalyzer from "@next/bundle-analyzer"; import bundleAnalyzer from "@next/bundle-analyzer";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import { withSentryConfig } from "@sentry/nextjs";
// Load the .env file from the working directory // Load the .env file from the working directory
dotenv.config({ path: path.resolve(process.cwd(), ".env") }); dotenv.config({ path: path.resolve(process.cwd(), ".env") });
@@ -33,20 +31,22 @@ const nextConfig: NextConfig = {
}, },
// Performance optimizations // Performance optimizations
experimental: experimental: {
process.env.NODE_ENV === "production" // Tree-shake barrel-file packages in both dev and production
? { optimizePackageImports: ["lucide-react", "framer-motion", "react-icons", "@tiptap/react"],
optimizePackageImports: ["lucide-react", "framer-motion"], // 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,
// In development, enable webpack build worker for faster builds // Note: optimizeCss (critters) is intentionally disabled — it converts the main
webpackBuildWorker: true, // <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 // Image optimization
images: { images: {
formats: ["image/webp", "image/avif"], formats: ["image/webp", "image/avif"],
minimumCacheTTL: 60, minimumCacheTTL: 2592000,
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
@@ -97,17 +97,6 @@ const nextConfig: NextConfig = {
}; };
if (!isServer) { 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 // Suppress framer-motion source map errors in development
config.plugins.push( config.plugins.push(
new webpack.SourceMapDevToolPlugin({ 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: [ headers: [
{ {
key: "Cache-Control", key: "Cache-Control",
@@ -200,42 +217,4 @@ const withBundleAnalyzer = bundleAnalyzer({
const withNextIntl = createNextIntlPlugin("./i18n/request.ts"); const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
// Wrap with Sentry export default withBundleAnalyzer(withNextIntl(nextConfig));
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,
},
}
);

3629
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -54,10 +54,7 @@
"dependencies": { "dependencies": {
"@next/bundle-analyzer": "^15.1.7", "@next/bundle-analyzer": "^15.1.7",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@react-three/drei": "^10.7.7", "@swc/helpers": "^0.5.19",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.36.0",
"@shadergradient/react": "^2.4.20",
"@tiptap/extension-color": "^3.15.3", "@tiptap/extension-color": "^3.15.3",
"@tiptap/extension-highlight": "^3.15.3", "@tiptap/extension-highlight": "^3.15.3",
"@tiptap/extension-link": "^3.15.3", "@tiptap/extension-link": "^3.15.3",
@@ -66,16 +63,13 @@
"@tiptap/html": "^3.15.3", "@tiptap/html": "^3.15.3",
"@tiptap/react": "^3.15.3", "@tiptap/react": "^3.15.3",
"@tiptap/starter-kit": "^3.15.3", "@tiptap/starter-kit": "^3.15.3",
"@types/three": "^0.182.0",
"@vercel/og": "^0.6.5", "@vercel/og": "^0.6.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"framer-motion": "^12.24.10", "framer-motion": "^12.24.10",
"gray-matter": "^4.0.3",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"next": "^15.5.7", "next": "^15.5.7",
"next-intl": "^4.7.0", "next-intl": "^4.7.0",
"next-themes": "^0.4.6",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"nodemailer": "^7.0.11", "nodemailer": "^7.0.11",
@@ -83,13 +77,16 @@
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-responsive-masonry": "^2.7.1",
"redis": "^5.8.2", "redis": "^5.8.2",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0"
"three": "^0.182.0",
"zod": "^4.3.5"
}, },
"browserslist": [
"chrome >= 100",
"firefox >= 100",
"safari >= 15.4",
"edge >= 100"
],
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
@@ -102,10 +99,9 @@
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-responsive-masonry": "^2.6.0",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/sanitize-html": "^2.16.0", "@types/sanitize-html": "^2.16.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"critters": "^0.0.23",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^15.5.7", "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. // 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 { } else {
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy"); 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: { theme: {
extend: { 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: { colors: {
background: "var(--background)", background: "var(--background)",
foreground: "var(--foreground)", foreground: "var(--foreground)",