Compare commits
104 Commits
b7b7ac8207
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b155369d | ||
|
|
dda996f0f8 | ||
|
|
63960f7581 | ||
|
|
bdf02b2a3a | ||
|
|
dacec18956 | ||
|
|
7f7ed39b0e | ||
|
|
1c49289386 | ||
|
|
34a81a6437 | ||
|
|
fa48610e3e | ||
|
|
a38f97c318 | ||
|
|
d7958b3841 | ||
|
|
7f9d39c275 | ||
|
|
69ae53809b | ||
|
|
4a8cb5867f | ||
|
|
77db462c22 | ||
|
|
5fc3236775 | ||
|
|
9ae6ada0a6 | ||
|
|
08315433d1 | ||
|
|
10a545f014 | ||
|
|
d80c936c60 | ||
|
|
2db9018477 | ||
|
|
eff17f76d3 | ||
|
|
30d0e597c2 | ||
|
|
74b73d1b84 | ||
|
|
42850ea17c | ||
|
|
9fd530c68f | ||
|
|
60ea4e99be | ||
|
|
de3ef37b48 | ||
|
|
f62db69289 | ||
|
|
0f7ea8ca4d | ||
|
|
c00fe6b06c | ||
|
|
dcaa1f8c3c | ||
|
|
c49493bb44 | ||
|
|
c9cd2d734d | ||
|
|
ef72f5fc58 | ||
|
|
8b440dd60b | ||
|
|
9a55dc7f81 | ||
|
|
3ac7c7a5b3 | ||
|
|
96d7ae5747 | ||
|
|
f7b7eaeaff | ||
|
|
32e621df14 | ||
|
|
6c5297836c | ||
|
|
9c7e564f6f | ||
|
|
4046a3c5b3 | ||
|
|
3e83dcfa15 | ||
|
|
b0ec4fd4b7 | ||
|
|
6ee52ffc8e | ||
|
|
450fe1b3eb | ||
|
|
f1d42818ee | ||
|
|
e0e0551a83 | ||
|
|
97c600df14 | ||
|
|
6c47cdbd83 | ||
|
|
21513b20c4 | ||
|
|
bd6007f299 | ||
|
|
b162fc8a4f | ||
|
|
a5449d2adb | ||
|
|
a5048634b8 | ||
|
|
b5d64b3f0a | ||
|
|
d21669ee6d | ||
|
|
3fd7329dc5 | ||
|
|
c449e9e0a8 | ||
|
|
689cfa18cf | ||
|
|
6fd4756f35 | ||
|
|
a5dba298f3 | ||
|
|
6f62b37c3a | ||
|
|
6213a4875a | ||
|
|
0684231308 | ||
|
|
739ee8a825 | ||
|
|
91eb446ac5 | ||
|
|
7955dfbabb | ||
|
|
7603cb6298 | ||
|
|
c3f55c92ed | ||
|
|
f5081f8765 | ||
|
|
b6eb24f2e8 | ||
|
|
9fd8c25dc6 | ||
|
|
cfd2f9f248 | ||
|
|
cd3726063c | ||
|
|
3cf1b9144d | ||
|
|
18f8fb7407 | ||
|
|
332adab08c | ||
|
|
5347a9ff3b | ||
|
|
0b1a45038d | ||
|
|
931843a5c6 | ||
|
|
0a0895cf89 | ||
|
|
5576e41ce0 | ||
|
|
cc8fff14d2 | ||
|
|
6998a0e7a1 | ||
|
|
0766b46cc8 | ||
|
|
92e5b4936e | ||
|
|
99d0d1dba1 | ||
|
|
032568562c | ||
|
|
07741761cc | ||
|
|
4029cd660d | ||
|
|
b754af20e6 | ||
|
|
3f31d6f5bb | ||
|
|
8eff9106f5 | ||
|
|
af30449071 | ||
|
|
98c3ebb96c | ||
|
|
9e2040cefc | ||
|
|
719071345e | ||
|
|
efafd38b1a | ||
|
|
5c70b26508 | ||
|
|
ede591c89e | ||
|
|
2defd7a4a9 |
45
.claude/agents/backend-dev.md
Normal file
45
.claude/agents/backend-dev.md
Normal 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`
|
||||||
52
.claude/agents/code-reviewer.md
Normal file
52
.claude/agents/code-reviewer.md
Normal 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.
|
||||||
48
.claude/agents/debugger.md
Normal file
48
.claude/agents/debugger.md
Normal 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
|
||||||
39
.claude/agents/frontend-dev.md
Normal file
39
.claude/agents/frontend-dev.md
Normal 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
49
.claude/agents/tester.md
Normal 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`
|
||||||
35
.claude/rules/api-routes.md
Normal file
35
.claude/rules/api-routes.md
Normal 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
|
||||||
37
.claude/rules/components.md
Normal file
37
.claude/rules/components.md
Normal 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
38
.claude/rules/testing.md
Normal 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
25
.claude/settings.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
50
.claude/skills/add-section/SKILL.md
Normal file
50
.claude/skills/add-section/SKILL.md
Normal 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)
|
||||||
39
.claude/skills/check-quality/SKILL.md
Normal file
39
.claude/skills/check-quality/SKILL.md
Normal 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.
|
||||||
30
.claude/skills/review-changes/SKILL.md
Normal file
30
.claude/skills/review-changes/SKILL.md
Normal 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.
|
||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
branches: [ dev, main, production ]
|
branches: [ dev, main, production ]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: '20'
|
NODE_VERSION: '25'
|
||||||
DOCKER_IMAGE: portfolio-app
|
DOCKER_IMAGE: portfolio-app
|
||||||
CONTAINER_NAME: portfolio-app
|
CONTAINER_NAME: portfolio-app
|
||||||
|
|
||||||
|
|||||||
279
.gitea/workflows/ci.yml
Normal file
279
.gitea/workflows/ci.yml
Normal 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
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
name: Dev Deployment (Zero Downtime)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ dev ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '20'
|
|
||||||
DOCKER_IMAGE: portfolio-app
|
|
||||||
IMAGE_TAG: dev
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-dev:
|
|
||||||
runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linting
|
|
||||||
run: npm run lint
|
|
||||||
continue-on-error: true # Don't block dev deployments on lint errors
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm run test
|
|
||||||
continue-on-error: true # Don't block dev deployments on test failures
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
echo "🏗️ Building dev Docker image with BuildKit cache..."
|
|
||||||
DOCKER_BUILDKIT=1 docker build \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
|
||||||
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
.
|
|
||||||
echo "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
- name: Zero-Downtime Dev Deployment
|
|
||||||
run: |
|
|
||||||
echo "🚀 Starting zero-downtime dev deployment..."
|
|
||||||
|
|
||||||
CONTAINER_NAME="portfolio-app-dev"
|
|
||||||
HEALTH_PORT="3001"
|
|
||||||
IMAGE_NAME="${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}"
|
|
||||||
|
|
||||||
# Check for existing container (running or stopped)
|
|
||||||
EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "")
|
|
||||||
|
|
||||||
# Start DB and Redis if not running
|
|
||||||
echo "🗄️ Starting database and Redis..."
|
|
||||||
COMPOSE_FILE="docker-compose.dev.minimal.yml"
|
|
||||||
|
|
||||||
# Stop and remove existing containers to ensure clean start with correct architecture
|
|
||||||
echo "🧹 Cleaning up existing containers..."
|
|
||||||
docker stop portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true
|
|
||||||
docker rm portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove old images to force re-pull with correct architecture
|
|
||||||
echo "🔄 Removing old images to force re-pull..."
|
|
||||||
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true
|
|
||||||
|
|
||||||
# Pull images with correct architecture (Docker will auto-detect)
|
|
||||||
echo "📥 Pulling images for current architecture..."
|
|
||||||
docker compose -f $COMPOSE_FILE pull postgres redis
|
|
||||||
|
|
||||||
# Start containers
|
|
||||||
echo "📦 Starting PostgreSQL and Redis containers..."
|
|
||||||
docker compose -f $COMPOSE_FILE up -d postgres redis
|
|
||||||
|
|
||||||
# Wait for DB to be ready
|
|
||||||
echo "⏳ Waiting for database to be ready..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if docker exec portfolio_postgres_dev pg_isready -U portfolio_user -d portfolio_dev >/dev/null 2>&1; then
|
|
||||||
echo "✅ Database is ready!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting for database... ($i/30)"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Export environment variables
|
|
||||||
export NODE_ENV=production
|
|
||||||
export LOG_LEVEL=${LOG_LEVEL:-debug}
|
|
||||||
export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev}
|
|
||||||
export DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public"
|
|
||||||
export REDIS_URL="redis://portfolio_redis_dev:6379"
|
|
||||||
export MY_EMAIL=${MY_EMAIL}
|
|
||||||
export MY_INFO_EMAIL=${MY_INFO_EMAIL}
|
|
||||||
export MY_PASSWORD=${MY_PASSWORD}
|
|
||||||
export MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
|
||||||
export ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH}
|
|
||||||
export ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
|
||||||
export N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''}
|
|
||||||
export N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''}
|
|
||||||
export PORT=${HEALTH_PORT}
|
|
||||||
|
|
||||||
# Stop and remove existing container if it exists (running or stopped)
|
|
||||||
if [ ! -z "$EXISTING_CONTAINER" ]; then
|
|
||||||
echo "🛑 Stopping and removing existing container..."
|
|
||||||
docker stop $EXISTING_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $EXISTING_CONTAINER 2>/dev/null || true
|
|
||||||
echo "✅ Old container removed"
|
|
||||||
# Wait for Docker to release the port
|
|
||||||
echo "⏳ Waiting for Docker to release port ${HEALTH_PORT}..."
|
|
||||||
sleep 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if port is still in use by Docker containers (check all containers, not just running)
|
|
||||||
PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "")
|
|
||||||
if [ ! -z "$PORT_CONTAINER" ]; then
|
|
||||||
echo "⚠️ Port ${HEALTH_PORT} is still in use by container $PORT_CONTAINER"
|
|
||||||
echo "🛑 Stopping and removing container using port..."
|
|
||||||
docker stop $PORT_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $PORT_CONTAINER 2>/dev/null || true
|
|
||||||
sleep 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also check for any containers with the same name that might be using the port
|
|
||||||
SAME_NAME_CONTAINER=$(docker ps -a -q -f name=$CONTAINER_NAME | head -1 || echo "")
|
|
||||||
if [ ! -z "$SAME_NAME_CONTAINER" ] && [ "$SAME_NAME_CONTAINER" != "$EXISTING_CONTAINER" ]; then
|
|
||||||
echo "⚠️ Found another container with same name: $SAME_NAME_CONTAINER"
|
|
||||||
docker stop $SAME_NAME_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $SAME_NAME_CONTAINER 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also check if port is in use by another process (non-Docker)
|
|
||||||
PORT_IN_USE=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || ss -tlnp | grep ":${HEALTH_PORT} " | head -1 || echo "")
|
|
||||||
if [ ! -z "$PORT_IN_USE" ] && [ -z "$PORT_CONTAINER" ]; then
|
|
||||||
echo "⚠️ Port ${HEALTH_PORT} is in use by process"
|
|
||||||
echo "Attempting to free the port..."
|
|
||||||
# Try to find and kill the process
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
|
||||||
PID=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "")
|
|
||||||
if [ ! -z "$PID" ]; then
|
|
||||||
kill -9 $PID 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Final check: verify port is free and wait if needed
|
|
||||||
echo "🔍 Verifying port ${HEALTH_PORT} is free..."
|
|
||||||
MAX_WAIT=10
|
|
||||||
WAIT_COUNT=0
|
|
||||||
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
|
|
||||||
PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "")
|
|
||||||
if [ -z "$PORT_CHECK" ]; then
|
|
||||||
# Also check with lsof/ss if available
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
|
||||||
PORT_CHECK=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "")
|
|
||||||
elif command -v ss >/dev/null 2>&1; then
|
|
||||||
PORT_CHECK=$(ss -tlnp | grep ":${HEALTH_PORT} " || echo "")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ -z "$PORT_CHECK" ]; then
|
|
||||||
echo "✅ Port ${HEALTH_PORT} is free!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
WAIT_COUNT=$((WAIT_COUNT + 1))
|
|
||||||
echo "⏳ Port still in use, waiting... ($WAIT_COUNT/$MAX_WAIT)"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# If port is still in use, try alternative port
|
|
||||||
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
|
|
||||||
echo "⚠️ Port ${HEALTH_PORT} is still in use after waiting. Trying alternative port..."
|
|
||||||
HEALTH_PORT="3002"
|
|
||||||
echo "🔄 Using alternative port: ${HEALTH_PORT}"
|
|
||||||
# Quick check if alternative port is also in use
|
|
||||||
ALT_PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "")
|
|
||||||
if [ ! -z "$ALT_PORT_CHECK" ]; then
|
|
||||||
echo "❌ Alternative port ${HEALTH_PORT} is also in use!"
|
|
||||||
echo "Attempting to free alternative port..."
|
|
||||||
ALT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "")
|
|
||||||
if [ ! -z "$ALT_CONTAINER" ]; then
|
|
||||||
docker stop $ALT_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $ALT_CONTAINER 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure networks exist
|
|
||||||
echo "🌐 Checking for networks..."
|
|
||||||
if ! docker network inspect proxy >/dev/null 2>&1; then
|
|
||||||
echo "⚠️ Proxy network not found, creating it..."
|
|
||||||
docker network create proxy 2>/dev/null || echo "Network might already exist or creation failed"
|
|
||||||
else
|
|
||||||
echo "✅ Proxy network exists"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker network inspect portfolio_dev >/dev/null 2>&1; then
|
|
||||||
echo "⚠️ Portfolio dev network not found, creating it..."
|
|
||||||
docker network create portfolio_dev 2>/dev/null || echo "Network might already exist or creation failed"
|
|
||||||
else
|
|
||||||
echo "✅ Portfolio dev network exists"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Connect proxy network to portfolio_dev network if needed
|
|
||||||
# (This allows the app to access both proxy and DB/Redis)
|
|
||||||
|
|
||||||
# Start new container with updated image
|
|
||||||
echo "🆕 Starting new dev container..."
|
|
||||||
docker run -d \
|
|
||||||
--name $CONTAINER_NAME \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network portfolio_dev \
|
|
||||||
-p ${HEALTH_PORT}:3000 \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
-e LOG_LEVEL=${LOG_LEVEL:-debug} \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \
|
|
||||||
-e DATABASE_URL=${DATABASE_URL} \
|
|
||||||
-e REDIS_URL=${REDIS_URL} \
|
|
||||||
-e MY_EMAIL=${MY_EMAIL} \
|
|
||||||
-e MY_INFO_EMAIL=${MY_INFO_EMAIL} \
|
|
||||||
-e MY_PASSWORD=${MY_PASSWORD} \
|
|
||||||
-e MY_INFO_PASSWORD=${MY_INFO_PASSWORD} \
|
|
||||||
-e ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} \
|
|
||||||
-e ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} \
|
|
||||||
-e N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} \
|
|
||||||
-e N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} \
|
|
||||||
$IMAGE_NAME
|
|
||||||
|
|
||||||
# Connect container to proxy network as well (for external access)
|
|
||||||
echo "🔗 Connecting container to proxy network..."
|
|
||||||
docker network connect proxy $CONTAINER_NAME 2>/dev/null || echo "Container might already be connected to proxy network"
|
|
||||||
|
|
||||||
# Wait for new container to be healthy
|
|
||||||
echo "⏳ Waiting for new container to be healthy..."
|
|
||||||
HEALTH_CHECK_PASSED=false
|
|
||||||
for i in {1..60}; do
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
# Check Docker health status
|
|
||||||
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
if [ "$HEALTH" == "healthy" ]; then
|
|
||||||
echo "✅ New container is healthy!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# Also check HTTP health endpoint
|
|
||||||
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ New container is responding!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting... ($i/60)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Verify new container is working
|
|
||||||
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
|
|
||||||
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
|
|
||||||
docker logs $CONTAINER_NAME --tail=50
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove old container if it exists and is different
|
|
||||||
if [ ! -z "$OLD_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
|
||||||
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
|
||||||
echo "🧹 Removing old container..."
|
|
||||||
docker stop $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Dev deployment completed!"
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
|
||||||
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
|
|
||||||
DATABASE_URL: postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public
|
|
||||||
REDIS_URL: redis://portfolio_redis_dev:6379
|
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
|
||||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
|
||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
|
||||||
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
|
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
|
||||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
|
||||||
|
|
||||||
- name: Dev Health Check
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running dev health checks..."
|
|
||||||
for i in {1..20}; do
|
|
||||||
if curl -f http://localhost:3001/api/health && curl -f http://localhost:3001/ > /dev/null; then
|
|
||||||
echo "✅ Dev is fully operational!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting for dev... ($i/20)"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
|
|
||||||
docker logs portfolio-app-dev --tail=50
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
run: |
|
|
||||||
echo "🧹 Cleaning up old images..."
|
|
||||||
docker image prune -f
|
|
||||||
echo "✅ Cleanup completed"
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
name: Production Deployment (Zero Downtime)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ production ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '20'
|
|
||||||
DOCKER_IMAGE: portfolio-app
|
|
||||||
IMAGE_TAG: production
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-production:
|
|
||||||
runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linting and tests in parallel
|
|
||||||
run: |
|
|
||||||
npm run lint &
|
|
||||||
LINT_PID=$!
|
|
||||||
npm run test:production &
|
|
||||||
TEST_PID=$!
|
|
||||||
wait $LINT_PID $TEST_PID
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
echo "🏗️ Building production Docker image with BuildKit cache..."
|
|
||||||
DOCKER_BUILDKIT=1 docker build \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
|
||||||
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
-t ${{ env.DOCKER_IMAGE }}:latest \
|
|
||||||
.
|
|
||||||
echo "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
- name: Zero-Downtime Production Deployment
|
|
||||||
run: |
|
|
||||||
echo "🚀 Starting zero-downtime production deployment..."
|
|
||||||
|
|
||||||
COMPOSE_FILE="docker-compose.production.yml"
|
|
||||||
CONTAINER_NAME="portfolio-app"
|
|
||||||
HEALTH_PORT="3000"
|
|
||||||
|
|
||||||
# Backup current container ID if running (exact name match to avoid staging)
|
|
||||||
OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "")
|
|
||||||
|
|
||||||
# Export environment variables for docker-compose
|
|
||||||
export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}"
|
|
||||||
export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}"
|
|
||||||
export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}"
|
|
||||||
|
|
||||||
# Also export other variables that docker-compose needs
|
|
||||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
|
||||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
|
||||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
|
||||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
|
||||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
|
||||||
export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}"
|
|
||||||
|
|
||||||
# Start new container with updated image (docker-compose will handle this)
|
|
||||||
echo "🆕 Starting new production container..."
|
|
||||||
echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}"
|
|
||||||
docker compose -f $COMPOSE_FILE up -d --no-deps --build portfolio
|
|
||||||
|
|
||||||
# Wait for new container to be healthy
|
|
||||||
echo "⏳ Waiting for new container to be healthy..."
|
|
||||||
HEALTH_CHECK_PASSED=false
|
|
||||||
for i in {1..90}; do
|
|
||||||
# Get the production container ID (exact name match, exclude staging)
|
|
||||||
# Use compose project to ensure we get the right container
|
|
||||||
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
# Fallback: try exact name match with leading slash
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
# Verify it's actually the production container by checking compose project label
|
|
||||||
CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "")
|
|
||||||
CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "")
|
|
||||||
if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then
|
|
||||||
# Check Docker health status first (most reliable)
|
|
||||||
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
if [ "$HEALTH" == "healthy" ]; then
|
|
||||||
echo "✅ New container is healthy (Docker health check)!"
|
|
||||||
# Also verify HTTP endpoint from inside container
|
|
||||||
if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ Container HTTP endpoint is also responding!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "⚠️ Docker health check passed, but HTTP endpoint test failed. Continuing..."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Try HTTP health endpoint from host (may not work if port not mapped yet)
|
|
||||||
if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ New container is responding to HTTP health check from host!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# Show container status for debugging
|
|
||||||
if [ $((i % 10)) -eq 0 ]; then
|
|
||||||
echo "📊 Container ID: $NEW_CONTAINER"
|
|
||||||
echo "📊 Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')"
|
|
||||||
echo "📊 Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')"
|
|
||||||
echo "📊 Health status: $HEALTH"
|
|
||||||
echo "📊 Testing from inside container:"
|
|
||||||
docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed"
|
|
||||||
docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ Found container but it's not from production compose file (skipping): $NEW_CONTAINER"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting... ($i/90)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Final verification: Check Docker health status (most reliable)
|
|
||||||
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
if [ "$FINAL_HEALTH" == "healthy" ]; then
|
|
||||||
echo "✅ Final verification: Container is healthy!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify new container is working
|
|
||||||
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
|
|
||||||
echo "❌ New container failed health check!"
|
|
||||||
echo "📋 All running containers with 'portfolio' in name:"
|
|
||||||
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"
|
|
||||||
echo "📋 Production container from compose:"
|
|
||||||
docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose"
|
|
||||||
echo "📋 Container logs:"
|
|
||||||
docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs"
|
|
||||||
|
|
||||||
# Get the correct container ID
|
|
||||||
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
echo "📋 Container inspect (ID: $NEW_CONTAINER):"
|
|
||||||
docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found"
|
|
||||||
echo "📋 Testing health endpoint from inside container:"
|
|
||||||
docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
|
|
||||||
|
|
||||||
# Check Docker health status - if it's healthy, accept it
|
|
||||||
FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then
|
|
||||||
echo "✅ Docker health check reports healthy - accepting deployment!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
else
|
|
||||||
echo "❌ Docker health check also reports: $FINAL_HEALTH_CHECK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ Could not find production container!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove old container if it exists and is different
|
|
||||||
if [ ! -z "$OLD_CONTAINER" ]; then
|
|
||||||
# Get the new production container ID
|
|
||||||
NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
|
||||||
echo "🧹 Removing old container..."
|
|
||||||
docker stop $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Production deployment completed with zero downtime!"
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
|
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
|
||||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
|
||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
|
||||||
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
|
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
|
||||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
|
||||||
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
|
|
||||||
|
|
||||||
- name: Production Health Check
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running production health checks..."
|
|
||||||
COMPOSE_FILE="docker-compose.production.yml"
|
|
||||||
CONTAINER_NAME="portfolio-app"
|
|
||||||
|
|
||||||
# Get the production container ID
|
|
||||||
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
|
||||||
CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
|
||||||
echo "❌ Production container not found!"
|
|
||||||
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📦 Found container: $CONTAINER_ID"
|
|
||||||
|
|
||||||
# Wait for container to be healthy (using Docker's health check)
|
|
||||||
HEALTH_CHECK_PASSED=false
|
|
||||||
for i in {1..30}; do
|
|
||||||
HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then
|
|
||||||
echo "✅ Container is healthy and running!"
|
|
||||||
|
|
||||||
# Test from inside the container (most reliable)
|
|
||||||
if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ Health endpoint responds from inside container!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "⚠️ Container is healthy but HTTP endpoint test failed. Retrying..."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $((i % 5)) -eq 0 ]; then
|
|
||||||
echo "📊 Status: $STATUS, Health: $HEALTH (attempt $i/30)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⏳ Waiting for production... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
|
|
||||||
echo "❌ Production health check failed!"
|
|
||||||
echo "📋 Container status:"
|
|
||||||
docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container"
|
|
||||||
echo "📋 Container logs:"
|
|
||||||
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs"
|
|
||||||
echo "📋 Testing from inside container:"
|
|
||||||
docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Production is fully operational!"
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
run: |
|
|
||||||
echo "🧹 Cleaning up old images..."
|
|
||||||
docker image prune -f
|
|
||||||
echo "✅ Cleanup completed"
|
|
||||||
107
.github/copilot-instructions.md
vendored
Normal file
107
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Portfolio Project Instructions
|
||||||
|
|
||||||
|
Dennis Konkol's portfolio (dk0.dev) — Next.js 15, Directus CMS, n8n automation, "Liquid Editorial Bento" design system.
|
||||||
|
|
||||||
|
## Build, Test, and Lint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:next # Plain Next.js dev server (no Docker)
|
||||||
|
npm run build # Production build (standalone mode)
|
||||||
|
npm run lint # ESLint (0 errors required, warnings OK)
|
||||||
|
npm run lint:fix # Auto-fix lint issues
|
||||||
|
npm run test # All Jest unit tests
|
||||||
|
npx jest path/to/test.tsx # Run a single test file
|
||||||
|
npm run test:watch # Watch mode
|
||||||
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
npm run db:generate # Regenerate Prisma client after schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server/Client Component Split
|
||||||
|
|
||||||
|
The homepage uses a **server component orchestrator** pattern:
|
||||||
|
|
||||||
|
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps client sections in `ScrollFadeIn`
|
||||||
|
- `app/components/Hero.tsx` — **server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
|
||||||
|
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`, each wrapping their component in a scoped `NextIntlClientProvider` with only the needed translation keys
|
||||||
|
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
|
||||||
|
|
||||||
|
### SSR Animation Safety
|
||||||
|
|
||||||
|
**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if hydration fails.
|
||||||
|
|
||||||
|
Use `ScrollFadeIn` component instead (`app/components/ScrollFadeIn.tsx`): renders no inline style during SSR (content visible by default), applies opacity+transform only after `hasMounted` check, animates via IntersectionObserver + CSS transitions.
|
||||||
|
|
||||||
|
Framer Motion `AnimatePresence` is fine for modals/overlays that only render after user interaction.
|
||||||
|
|
||||||
|
### Data Source Fallback Chain
|
||||||
|
|
||||||
|
Every data fetch degrades gracefully — the site never crashes:
|
||||||
|
|
||||||
|
1. **Directus CMS** → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
|
||||||
|
|
||||||
|
### CMS Integration (Directus)
|
||||||
|
|
||||||
|
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
|
||||||
|
- Returns `null` on failure (never throws)
|
||||||
|
- Locale mapping: `en` → `en-US`, `de` → `de-DE`
|
||||||
|
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and return `source` field (`directus|fallback|error`)
|
||||||
|
|
||||||
|
### n8n Integration
|
||||||
|
|
||||||
|
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
|
||||||
|
- Auth: `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||||
|
- All endpoints have rate limiting and 10s timeout
|
||||||
|
- Hardcover reading data cached 5 minutes
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Locales: `en`, `de` — defined in `middleware.ts`, must match `app/[locale]/layout.tsx`
|
||||||
|
- Client components: `useTranslations("key.path")` from `next-intl`
|
||||||
|
- Server components: `getTranslations("key.path")` from `next-intl/server`
|
||||||
|
- Always add keys to both `messages/en.json` and `messages/de.json`
|
||||||
|
|
||||||
|
### Design System
|
||||||
|
|
||||||
|
- Custom Tailwind colors: `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`, `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
|
||||||
|
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`
|
||||||
|
- Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<span className="text-emerald-600">.</span>`)
|
||||||
|
- Layout: Bento Grid, no floating overlays
|
||||||
|
- Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400`) for body text — contrast ratio must be ≥4.5:1
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- TypeScript: no `any` — use interfaces from `lib/directus.ts` or `types/`
|
||||||
|
- Error logging: `console.error` only when `process.env.NODE_ENV === "development"`
|
||||||
|
- File naming: PascalCase components (`About.tsx`), kebab-case API routes (`book-reviews/`), kebab-case lib utils
|
||||||
|
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||||
|
- Every async component needs a Skeleton loading state
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Jest with JSDOM; mocks for `window.matchMedia` and `IntersectionObserver` in `jest.setup.ts`
|
||||||
|
- ESM modules transformed via `transformIgnorePatterns` (react-markdown, remark-*, etc.)
|
||||||
|
- Server component tests: `const resolved = await Component({ props }); render(resolved)`
|
||||||
|
- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `<img>` tag
|
||||||
|
|
||||||
|
### Docker & Deployment
|
||||||
|
|
||||||
|
- `output: "standalone"` in `next.config.ts`
|
||||||
|
- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal on failure), starts server
|
||||||
|
- CI/CD: `.gitea/workflows/ci.yml` — `test-build` job (all branches), `deploy-dev` (dev only), `deploy-production` (production only)
|
||||||
|
- Branches: `dev` → testing.dk0.dev, `production` → dk0.dev
|
||||||
|
- Dev and production share the same PostgreSQL and Redis instances
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a CMS-managed section
|
||||||
|
|
||||||
|
1. Define GraphQL query + types in `lib/directus.ts`
|
||||||
|
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
|
||||||
|
3. Create component in `app/components/<Name>.tsx` with Skeleton loading state
|
||||||
|
4. Add i18n keys to both `messages/en.json` and `messages/de.json`
|
||||||
|
5. Create a `<Name>Client` wrapper in `ClientWrappers.tsx` with scoped `NextIntlClientProvider`
|
||||||
|
6. Add to `HomePageServer.tsx` wrapped in `ScrollFadeIn`
|
||||||
334
.github/workflows/ci-cd.yml
vendored
334
.github/workflows/ci-cd.yml
vendored
@@ -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
9
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
135
CLAUDE.md
Normal file
135
CLAUDE.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "Liquid Editorial Bento" design system with soft gradient colors and glassmorphism effects.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 15 (App Router), TypeScript 5.9, React 19
|
||||||
|
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
||||||
|
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
|
||||||
|
- **Animations**: Framer Motion 12
|
||||||
|
- **3D**: Three.js + React Three Fiber + `@shadergradient/react` (shader gradient background)
|
||||||
|
- **Database**: PostgreSQL via Prisma ORM
|
||||||
|
- **Cache**: Redis (optional)
|
||||||
|
- **CMS**: Directus (self-hosted, GraphQL only, optional)
|
||||||
|
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
||||||
|
- **i18n**: next-intl (EN + DE), message files in `messages/`
|
||||||
|
- **Deployment**: Docker + Nginx, CI via Gitea Actions (`output: "standalone"`)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Full dev environment (Docker + Next.js)
|
||||||
|
npm run dev:simple # Next.js only (no Docker)
|
||||||
|
npm run dev:next # Plain Next.js dev server
|
||||||
|
npm run build # Production build
|
||||||
|
npm run lint # ESLint (0 errors required, warnings OK)
|
||||||
|
npm run lint:fix # Auto-fix lint issues
|
||||||
|
npm run test # All Jest unit tests
|
||||||
|
npx jest path/to/test.tsx # Run a single test file
|
||||||
|
npm run test:watch # Watch mode
|
||||||
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
npm run db:generate # Regenerate Prisma client after schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Server/Client Component Split
|
||||||
|
|
||||||
|
The homepage uses a **server component orchestrator** pattern:
|
||||||
|
|
||||||
|
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps below-fold sections in `ScrollFadeIn`
|
||||||
|
- `app/components/Hero.tsx` — **server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
|
||||||
|
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`; each wraps its component in a scoped `NextIntlClientProvider` with only the needed translation namespace
|
||||||
|
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
|
||||||
|
|
||||||
|
### SSR Animation Safety
|
||||||
|
|
||||||
|
**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if JS hydration fails or is slow.
|
||||||
|
|
||||||
|
Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) instead: renders no inline style during SSR, applies opacity+transform only after `hasMounted` check via IntersectionObserver + CSS transitions.
|
||||||
|
|
||||||
|
`AnimatePresence` is fine for modals/overlays that only render after user interaction.
|
||||||
|
|
||||||
|
### Data Source Fallback Chain
|
||||||
|
|
||||||
|
Every data fetch degrades gracefully — the site never crashes:
|
||||||
|
|
||||||
|
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured) → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
|
||||||
|
|
||||||
|
### CMS Integration (Directus)
|
||||||
|
|
||||||
|
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
|
||||||
|
- Returns `null` on failure, never throws
|
||||||
|
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
||||||
|
- Translations use Directus native M2O system; locale mapping: `en` → `en-US`, `de` → `de-DE`
|
||||||
|
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and include a `source` field in the response (`"directus"` | `"fallback"` | `"error"`)
|
||||||
|
|
||||||
|
### n8n Integration
|
||||||
|
|
||||||
|
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
|
||||||
|
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||||
|
- All endpoints have rate limiting and 10s timeout
|
||||||
|
- Hardcover reading data cached 5 minutes
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
Custom Tailwind colors prefixed with `liquid-`:
|
||||||
|
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
|
||||||
|
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
|
||||||
|
|
||||||
|
Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`.
|
||||||
|
|
||||||
|
Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<span className="text-emerald-600">.</span>`).
|
||||||
|
|
||||||
|
Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400` alone) for body text — contrast ratio must be ≥4.5:1.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **TypeScript**: No `any` — use interfaces from `lib/directus.ts` or `types/`
|
||||||
|
- **Components**: PascalCase files in `app/components/`; every async component needs a Skeleton loading state
|
||||||
|
- **API routes**: kebab-case directories in `app/api/`
|
||||||
|
- **i18n**: Always add keys to both `messages/en.json` and `messages/de.json`; `useTranslations()` in client, `getTranslations()` in server components
|
||||||
|
- **Error logging**: `console.error` only when `process.env.NODE_ENV === "development"`
|
||||||
|
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||||
|
- **No emojis** in code unless explicitly requested
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
- Jest with JSDOM; `jest.setup.ts` mocks `window.matchMedia`, `IntersectionObserver`, and `NextResponse`
|
||||||
|
- ESM modules (react-markdown, remark-*, etc.) handled via `transformIgnorePatterns` in `jest.config.ts`
|
||||||
|
- Server component tests: `const resolved = await Component({ props }); render(resolved)`
|
||||||
|
- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `<img>` tag
|
||||||
|
|
||||||
|
## Deployment & CI/CD
|
||||||
|
|
||||||
|
- `output: "standalone"` in `next.config.ts`
|
||||||
|
- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal), starts server
|
||||||
|
- CI/CD: `.gitea/workflows/ci.yml` — `test-build` (all branches), `deploy-dev` (dev branch only), `deploy-production` (production branch only)
|
||||||
|
- **Branches**: `dev` → testing.dk0.dev | `production` → dk0.dev
|
||||||
|
- Dev and production share the same PostgreSQL and Redis instances
|
||||||
|
|
||||||
|
## Key Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DIRECTUS_URL=https://cms.dk0.dev
|
||||||
|
DIRECTUS_STATIC_TOKEN=...
|
||||||
|
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
||||||
|
N8N_SECRET_TOKEN=...
|
||||||
|
N8N_API_KEY=...
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
REDIS_URL=redis://... # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a CMS-managed Section
|
||||||
|
|
||||||
|
1. Define GraphQL query + types in `lib/directus.ts`
|
||||||
|
2. Create API route `app/api/<name>/route.ts` with `runtime='nodejs'`, `dynamic='force-dynamic'`, and `source` field in response
|
||||||
|
3. Create component `app/components/<Name>.tsx` with Skeleton loading state
|
||||||
|
4. Add i18n keys to both `messages/en.json` and `messages/de.json`
|
||||||
|
5. Create `<Name>Client` wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider`
|
||||||
|
6. Add to `app/_ui/HomePageServer.tsx` wrapped in `<ScrollFadeIn>`
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Directus Integration - Migration Guide
|
|
||||||
|
|
||||||
## 🎯 Overview
|
|
||||||
|
|
||||||
This portfolio now has a **hybrid i18n system**:
|
|
||||||
- ✅ **JSON Files** (Primary) → All translations work from `messages/*.json` files
|
|
||||||
- ✅ **Directus CMS** (Optional) → Can override translations dynamically without rebuilds
|
|
||||||
|
|
||||||
**Important**: Directus is **optional**. The app works perfectly fine without it using JSON fallbacks.
|
|
||||||
|
|
||||||
## 📁 New File Structure
|
|
||||||
|
|
||||||
### Core Infrastructure
|
|
||||||
- `lib/directus.ts` - REST Client for Directus (uses `de-DE`, `en-US` locale codes)
|
|
||||||
- `lib/i18n-loader.ts` - Loads texts with Fallback Chain
|
|
||||||
- `lib/translations-loader.ts` - Batch loader for all sections (cleaned up to match actual usage)
|
|
||||||
- `types/translations.ts` - TypeScript types for all translation objects (fixed to match components)
|
|
||||||
|
|
||||||
### Components
|
|
||||||
All component wrappers properly load and pass translations to client components.
|
|
||||||
|
|
||||||
## 🔄 How It Works
|
|
||||||
|
|
||||||
### Without Directus (Default)
|
|
||||||
```
|
|
||||||
Component → useTranslations("nav") → JSON File (messages/en.json)
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Directus (Optional)
|
|
||||||
```
|
|
||||||
Server Component → getNavTranslations(locale)
|
|
||||||
→ Try Directus API (de-DE/en-US)
|
|
||||||
→ If not found: JSON File (de/en)
|
|
||||||
→ Props to Client Component
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗄️ Directus Setup (Optional)
|
|
||||||
|
|
||||||
Only set this up if you want to edit translations through a CMS without rebuilding the app.
|
|
||||||
|
|
||||||
### 1. Environment Variables
|
|
||||||
|
|
||||||
Add to `.env.local`:
|
|
||||||
```bash
|
|
||||||
DIRECTUS_URL=https://cms.example.com
|
|
||||||
DIRECTUS_STATIC_TOKEN=your_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
**If these are not set**, the system will skip Directus and use JSON files only.
|
|
||||||
|
|
||||||
### 2. Collection: `messages`
|
|
||||||
|
|
||||||
Create a `messages` collection in Directus with these fields:
|
|
||||||
- `key` (String, required) - e.g., "nav.home"
|
|
||||||
- `translations` (Translations) - Directus native translations feature
|
|
||||||
- Configure languages: `en-US` and `de-DE`
|
|
||||||
|
|
||||||
**Note**: Keys use dot notation (`nav.home`) but locales use dashes (`en-US`, `de-DE`).
|
|
||||||
|
|
||||||
### 3. Permissions
|
|
||||||
|
|
||||||
Grant **Public** role read access to `messages` collection.
|
|
||||||
|
|
||||||
## 📝 Translation Keys
|
|
||||||
|
|
||||||
See `docs/LOCALE_SYSTEM.md` for the complete list of translation keys and their structure.
|
|
||||||
|
|
||||||
All keys are organized hierarchically:
|
|
||||||
- `nav.*` - Navigation items
|
|
||||||
- `home.hero.*` - Hero section
|
|
||||||
- `home.about.*` - About section
|
|
||||||
- `home.projects.*` - Projects section
|
|
||||||
- `home.contact.*` - Contact form and info
|
|
||||||
- `footer.*` - Footer content
|
|
||||||
- `consent.*` - Privacy consent banner
|
|
||||||
|
|
||||||
## 🎨 Rich Text Content
|
|
||||||
|
|
||||||
For longer content that needs formatting (bold, italic, lists), use the `content_pages` collection:
|
|
||||||
|
|
||||||
### Collection: `content_pages` (Optional)
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `slug` (String, unique) - e.g., "home-hero"
|
|
||||||
- `locale` (String) - `en` or `de`
|
|
||||||
- `title` (String)
|
|
||||||
- `content` (Rich Text or Long Text)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `home-hero` - Hero section description
|
|
||||||
- `home-about` - About section content
|
|
||||||
- `home-contact` - Contact intro text
|
|
||||||
|
|
||||||
Components fetch these via `/api/content/page` and render using `RichTextClient`.
|
|
||||||
|
|
||||||
## 🔍 Fallback Chain
|
|
||||||
|
|
||||||
For every translation key, the system searches in this order:
|
|
||||||
|
|
||||||
1. **Directus** (if configured) in requested locale (e.g., `de-DE`)
|
|
||||||
2. **Directus** in English fallback (e.g., `en-US`)
|
|
||||||
3. **JSON file** in requested locale (e.g., `messages/de.json`)
|
|
||||||
4. **JSON file** in English (e.g., `messages/en.json`)
|
|
||||||
5. **Key itself** as last resort (e.g., returns `"nav.home"`)
|
|
||||||
|
|
||||||
## ✅ What Was Fixed
|
|
||||||
|
|
||||||
Previous issues that have been resolved:
|
|
||||||
|
|
||||||
1. ✅ **Type mismatches** - All translation types now match actual component usage
|
|
||||||
2. ✅ **Unused fields** - Removed translation keys that were never used (like `hero.greeting`, `hero.name`)
|
|
||||||
3. ✅ **Wrong structure** - Fixed `AboutTranslations` structure (removed fake `interests` nesting)
|
|
||||||
4. ✅ **Missing keys** - Aligned loaders with JSON files and actual component requirements
|
|
||||||
5. ✅ **Confusing comments** - Removed misleading comments in `translations-loader.ts`
|
|
||||||
|
|
||||||
## 🎯 Best Practices
|
|
||||||
|
|
||||||
1. **Always maintain JSON files** - Even if using Directus, keep JSON files as fallback
|
|
||||||
2. **Use types** - TypeScript types ensure correct usage
|
|
||||||
3. **Test without Directus** - App should work perfectly without CMS configured
|
|
||||||
4. **Rich text for formatting** - Use `content_pages` for content that needs bold/italic/lists
|
|
||||||
5. **JSON for UI labels** - Use JSON/messages for short UI strings like buttons and labels
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Directus not configured
|
|
||||||
**This is normal!** The app works fine. All translations come from JSON files.
|
|
||||||
|
|
||||||
### Want to use Directus?
|
|
||||||
1. Set up `DIRECTUS_URL` and `DIRECTUS_STATIC_TOKEN`
|
|
||||||
2. Create `messages` collection
|
|
||||||
3. Add your translations
|
|
||||||
4. They will override JSON values
|
|
||||||
|
|
||||||
### Translation not showing?
|
|
||||||
Check in this order:
|
|
||||||
1. Does key exist in `messages/en.json`?
|
|
||||||
2. Is the key spelled correctly?
|
|
||||||
3. Is component using correct namespace?
|
|
||||||
|
|
||||||
## 📚 Further Reading
|
|
||||||
|
|
||||||
- **Complete locale documentation**: `docs/LOCALE_SYSTEM.md`
|
|
||||||
- **Directus setup checklist**: `DIRECTUS_CHECKLIST.md`
|
|
||||||
- **Operations guide**: `docs/OPERATIONS.md`
|
|
||||||
|
|
||||||
14
Dockerfile
14
Dockerfile
@@ -1,5 +1,5 @@
|
|||||||
# Multi-stage build for optimized production image
|
# Multi-stage build for optimized production image
|
||||||
FROM node:20 AS base
|
FROM node:25 AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
# Install dependencies only when needed
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
@@ -31,10 +31,10 @@ RUN npx prisma generate
|
|||||||
# Copy source code (this invalidates cache when code changes)
|
# Copy source code (this invalidates cache when code changes)
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the application
|
# Build the application (mount cache for faster rebuilds)
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||||
|
|
||||||
# Verify standalone output was created and show structure for debugging
|
# Verify standalone output was created and show structure for debugging
|
||||||
RUN if [ ! -d .next/standalone ]; then \
|
RUN if [ ! -d .next/standalone ]; then \
|
||||||
@@ -67,10 +67,6 @@ RUN adduser --system --uid 1001 nextjs
|
|||||||
# Copy the built application
|
# Copy the built application
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
# Set the correct permission for prerender cache
|
|
||||||
RUN mkdir .next
|
|
||||||
RUN chown nextjs:nodejs .next
|
|
||||||
|
|
||||||
# Automatically leverage output traces to reduce image size
|
# Automatically leverage output traces to reduce image size
|
||||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
# Copy standalone output (contains server.js and all dependencies)
|
# Copy standalone output (contains server.js and all dependencies)
|
||||||
@@ -79,6 +75,10 @@ RUN chown nextjs:nodejs .next
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Create cache directories with correct permissions AFTER copying standalone
|
||||||
|
RUN mkdir -p .next/cache/fetch-cache .next/cache/images && \
|
||||||
|
chown -R nextjs:nodejs .next/cache
|
||||||
|
|
||||||
# Copy Prisma files
|
# Copy Prisma files
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --from=builder /app/prisma ./prisma
|
||||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|||||||
34
GEMINI.md
Normal file
34
GEMINI.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Gemini CLI: Project Context & Engineering Mandates
|
||||||
|
|
||||||
|
## Project Identity
|
||||||
|
- **Name:** Dennis Konkol Portfolio (dk0.dev)
|
||||||
|
- **Aesthetic:** "Liquid Editorial Bento" (Premium, minimalistisch, hoch-typografisch).
|
||||||
|
- **Core Palette:** Creme (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`), Sky, Purple.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Framework:** Next.js 15 (App Router), Tailwind CSS 3.4.
|
||||||
|
- **CMS:** Directus (primär für Texte, Hobbies, Tech-Stack, Projekte).
|
||||||
|
- **Database:** PostgreSQL (Prisma) als lokaler Cache/Mirror für Projekte.
|
||||||
|
- **Animations:** Framer Motion (bevorzugt für alle Übergänge).
|
||||||
|
- **i18n:** `next-intl` (Locales: `en`, `de`).
|
||||||
|
|
||||||
|
## Engineering Guidelines (Mandates)
|
||||||
|
|
||||||
|
### 1. UI Components
|
||||||
|
- **Bento Grid:** Neue Features sollten immer in das bestehende Grid integriert werden. Keine schwebenden Overlays.
|
||||||
|
- **Skeletons:** Jede asynchrone Komponente benötigt einen passenden `Skeleton` Ladezustand.
|
||||||
|
- **Typography:** Headlines immer uppercase, tracking-tighter, mit Akzent-Punkt am Ende.
|
||||||
|
|
||||||
|
### 2. Implementation Rules
|
||||||
|
- **TypeScript:** Keine `any`. Nutze bestehende Interfaces in `lib/directus.ts` oder `app/_ui/`.
|
||||||
|
- **Resilience:** Alle API-Calls müssen Fehler abfangen und sinnvolle Fallbacks (oder Skeletons) anzeigen.
|
||||||
|
- **Next.js Standalone:** Das Projekt nutzt den `standalone` Build-Mode. Docker-Builds müssen immer verifiziert werden.
|
||||||
|
|
||||||
|
### 3. Agent Instructions
|
||||||
|
- **Codebase Investigator:** Nutze dieses Tool für Architektur-Fragen.
|
||||||
|
- **Testing:** Führe `npm run test` nach UI-Änderungen aus. Achte auf JSDOM-Einschränkungen (Mocking von `window.matchMedia` und `IntersectionObserver`).
|
||||||
|
- **CMS First:** Texte sollten nach Möglichkeit aus der `messages` Collection in Directus kommen, nicht hartcodiert werden.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- **Branch:** `dev` (pushed)
|
||||||
|
- **Status:** Design Overhaul abgeschlossen, Build stabil, Docker verifiziert.
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
# 🚀 Safe Push to Main Branch Guide
|
|
||||||
|
|
||||||
**IMPORTANT**: This guide ensures you don't break production when merging to main.
|
|
||||||
|
|
||||||
## ⚠️ Pre-Flight Checklist
|
|
||||||
|
|
||||||
Before even thinking about pushing to main, verify ALL of these:
|
|
||||||
|
|
||||||
### 1. Code Quality ✅
|
|
||||||
```bash
|
|
||||||
# Run all checks
|
|
||||||
npm run build # Must pass with 0 errors
|
|
||||||
npm run lint # Must pass with 0 errors
|
|
||||||
npx tsc --noEmit # TypeScript must be clean
|
|
||||||
npx prisma format # Database schema must be valid
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1b. Automated Testing ✅
|
|
||||||
```bash
|
|
||||||
# Run comprehensive test suite (RECOMMENDED)
|
|
||||||
npm run test:all # Runs all tests including E2E
|
|
||||||
|
|
||||||
# Or run individually:
|
|
||||||
npm run test # Unit tests
|
|
||||||
npm run test:critical # Critical path E2E tests
|
|
||||||
npm run test:hydration # Hydration tests
|
|
||||||
npm run test:email # Email API tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Testing ✅
|
|
||||||
```bash
|
|
||||||
# Automated testing (RECOMMENDED)
|
|
||||||
npm run test:all # Runs all automated tests
|
|
||||||
|
|
||||||
# Manual testing (if needed)
|
|
||||||
npm run dev
|
|
||||||
# Test these critical paths:
|
|
||||||
# - Home page loads
|
|
||||||
# - Projects page works
|
|
||||||
# - Admin dashboard accessible
|
|
||||||
# - API endpoints respond
|
|
||||||
# - No console errors
|
|
||||||
# - No hydration errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Database Changes ✅
|
|
||||||
```bash
|
|
||||||
# If you changed the database schema:
|
|
||||||
# 1. Create migration
|
|
||||||
npx prisma migrate dev --name your_migration_name
|
|
||||||
|
|
||||||
# 2. Test migration on a copy of production data
|
|
||||||
# 3. Document migration steps
|
|
||||||
# 4. Create rollback plan
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Environment Variables ✅
|
|
||||||
- [ ] All new env vars documented in `env.example`
|
|
||||||
- [ ] No secrets committed to git
|
|
||||||
- [ ] Production env vars are set on server
|
|
||||||
- [ ] Optional features have fallbacks
|
|
||||||
|
|
||||||
### 5. Breaking Changes ✅
|
|
||||||
- [ ] Documented in CHANGELOG
|
|
||||||
- [ ] Backward compatible OR migration plan exists
|
|
||||||
- [ ] Team notified of changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Step-by-Step Push Process
|
|
||||||
|
|
||||||
### Step 1: Ensure You're on Dev Branch
|
|
||||||
```bash
|
|
||||||
git checkout dev
|
|
||||||
git pull origin dev # Get latest changes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Final Verification
|
|
||||||
```bash
|
|
||||||
# Clean build
|
|
||||||
rm -rf .next node_modules/.cache
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Should complete without errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Review Your Changes
|
|
||||||
```bash
|
|
||||||
# See what you're about to push
|
|
||||||
git log origin/main..dev --oneline
|
|
||||||
git diff origin/main..dev
|
|
||||||
|
|
||||||
# Review carefully:
|
|
||||||
# - No accidental secrets
|
|
||||||
# - No debug code
|
|
||||||
# - No temporary files
|
|
||||||
# - All changes are intentional
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Create a Backup Branch (Safety Net)
|
|
||||||
```bash
|
|
||||||
# Create backup before merging
|
|
||||||
git checkout -b backup-before-main-merge-$(date +%Y%m%d)
|
|
||||||
git push origin backup-before-main-merge-$(date +%Y%m%d)
|
|
||||||
git checkout dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Merge Dev into Main (Local)
|
|
||||||
```bash
|
|
||||||
# Switch to main
|
|
||||||
git checkout main
|
|
||||||
git pull origin main # Get latest main
|
|
||||||
|
|
||||||
# Merge dev into main
|
|
||||||
git merge dev --no-ff -m "Merge dev into main: [describe changes]"
|
|
||||||
|
|
||||||
# If conflicts occur:
|
|
||||||
# 1. Resolve conflicts carefully
|
|
||||||
# 2. Test after resolving
|
|
||||||
# 3. Don't force push if unsure
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Test the Merged Code
|
|
||||||
```bash
|
|
||||||
# Build and test the merged code
|
|
||||||
npm run build
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Test critical paths again
|
|
||||||
# - Home page
|
|
||||||
# - Projects
|
|
||||||
# - Admin
|
|
||||||
# - APIs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Push to Main (If Everything Looks Good)
|
|
||||||
```bash
|
|
||||||
# Push to remote main
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# If you need to force push (DANGEROUS - only if necessary):
|
|
||||||
# git push origin main --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8: Monitor Deployment
|
|
||||||
```bash
|
|
||||||
# Watch your deployment logs
|
|
||||||
# Check for errors
|
|
||||||
# Verify health endpoints
|
|
||||||
# Test production site
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Safety Strategies
|
|
||||||
|
|
||||||
### Strategy 1: Feature Flags
|
|
||||||
If you're adding new features, use feature flags:
|
|
||||||
```typescript
|
|
||||||
// In your code
|
|
||||||
if (process.env.ENABLE_NEW_FEATURE === 'true') {
|
|
||||||
// New feature code
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Strategy 2: Gradual Rollout
|
|
||||||
- Deploy to staging first
|
|
||||||
- Test thoroughly
|
|
||||||
- Then deploy to production
|
|
||||||
- Monitor closely
|
|
||||||
|
|
||||||
### Strategy 3: Database Migrations
|
|
||||||
```bash
|
|
||||||
# Always test migrations first
|
|
||||||
# 1. Backup production database
|
|
||||||
# 2. Test migration on copy
|
|
||||||
# 3. Create rollback script
|
|
||||||
# 4. Run migration during low-traffic period
|
|
||||||
```
|
|
||||||
|
|
||||||
### Strategy 4: Rollback Plan
|
|
||||||
Always have a rollback plan:
|
|
||||||
```bash
|
|
||||||
# If something breaks:
|
|
||||||
git revert HEAD
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# Or rollback to previous commit:
|
|
||||||
git reset --hard <previous-commit-hash>
|
|
||||||
git push origin main --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Red Flags - DON'T PUSH IF:
|
|
||||||
|
|
||||||
- ❌ Build fails
|
|
||||||
- ❌ Tests fail
|
|
||||||
- ❌ Linter errors
|
|
||||||
- ❌ TypeScript errors
|
|
||||||
- ❌ Database migration not tested
|
|
||||||
- ❌ Breaking changes not documented
|
|
||||||
- ❌ Secrets in code
|
|
||||||
- ❌ Debug code left in
|
|
||||||
- ❌ Console.logs everywhere
|
|
||||||
- ❌ Untested features
|
|
||||||
- ❌ No rollback plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Green Lights - SAFE TO PUSH IF:
|
|
||||||
|
|
||||||
- ✅ All checks pass
|
|
||||||
- ✅ Tested locally
|
|
||||||
- ✅ Database migrations tested
|
|
||||||
- ✅ No breaking changes (or documented)
|
|
||||||
- ✅ Documentation updated
|
|
||||||
- ✅ Team notified
|
|
||||||
- ✅ Rollback plan exists
|
|
||||||
- ✅ Feature flags for new features
|
|
||||||
- ✅ Environment variables documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Pre-Push Checklist Template
|
|
||||||
|
|
||||||
Copy this and check each item:
|
|
||||||
|
|
||||||
```
|
|
||||||
[ ] npm run build passes
|
|
||||||
[ ] npm run lint passes
|
|
||||||
[ ] npx tsc --noEmit passes
|
|
||||||
[ ] npx prisma format passes
|
|
||||||
[ ] npm run test:all passes (automated tests)
|
|
||||||
[ ] OR manual testing:
|
|
||||||
[ ] Dev server starts without errors
|
|
||||||
[ ] Home page loads correctly
|
|
||||||
[ ] Projects page works
|
|
||||||
[ ] Admin dashboard accessible
|
|
||||||
[ ] API endpoints respond
|
|
||||||
[ ] No console errors
|
|
||||||
[ ] No hydration errors
|
|
||||||
[ ] Database migrations tested (if any)
|
|
||||||
[ ] Environment variables documented
|
|
||||||
[ ] No secrets in code
|
|
||||||
[ ] Breaking changes documented
|
|
||||||
[ ] CHANGELOG updated
|
|
||||||
[ ] Team notified (if needed)
|
|
||||||
[ ] Rollback plan exists
|
|
||||||
[ ] Backup branch created
|
|
||||||
[ ] Changes reviewed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Alternative: Pull Request Workflow
|
|
||||||
|
|
||||||
If you want extra safety, use PR workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Push dev branch
|
|
||||||
git push origin dev
|
|
||||||
|
|
||||||
# 2. Create Pull Request on Git platform
|
|
||||||
# - Review changes
|
|
||||||
# - Get approval
|
|
||||||
# - Run CI/CD checks
|
|
||||||
|
|
||||||
# 3. Merge PR to main (platform handles it)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Emergency Rollback
|
|
||||||
|
|
||||||
If production breaks after push:
|
|
||||||
|
|
||||||
### Quick Rollback
|
|
||||||
```bash
|
|
||||||
# 1. Revert the merge commit
|
|
||||||
git revert -m 1 <merge-commit-hash>
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# 2. Or reset to previous state
|
|
||||||
git reset --hard <previous-commit>
|
|
||||||
git push origin main --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Rollback
|
|
||||||
```bash
|
|
||||||
# If you ran migrations, roll them back:
|
|
||||||
npx prisma migrate resolve --rolled-back <migration-name>
|
|
||||||
|
|
||||||
# Or restore from backup
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Need Help?
|
|
||||||
|
|
||||||
If unsure:
|
|
||||||
1. **Don't push** - better safe than sorry
|
|
||||||
2. Test more thoroughly
|
|
||||||
3. Ask for code review
|
|
||||||
4. Use staging environment first
|
|
||||||
5. Create a PR for review
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Best Practices
|
|
||||||
|
|
||||||
1. **Always test locally first**
|
|
||||||
2. **Use feature flags for new features**
|
|
||||||
3. **Test database migrations on copies**
|
|
||||||
4. **Document everything**
|
|
||||||
5. **Have a rollback plan**
|
|
||||||
6. **Monitor after deployment**
|
|
||||||
7. **Deploy during low-traffic periods**
|
|
||||||
8. **Keep main branch stable**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Remember**: It's better to delay a push than to break production! 🛡️
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 🔒 Security Improvements
|
|
||||||
|
|
||||||
## Implemented Security Features
|
|
||||||
|
|
||||||
### 1. n8n API Endpoint Protection
|
|
||||||
|
|
||||||
All n8n endpoints are now protected with:
|
|
||||||
- **Authentication**: Admin authentication required for sensitive endpoints (`/api/n8n/generate-image`)
|
|
||||||
- **Rate Limiting**:
|
|
||||||
- `/api/n8n/generate-image`: 10 requests/minute
|
|
||||||
- `/api/n8n/chat`: 20 requests/minute
|
|
||||||
- `/api/n8n/status`: 30 requests/minute
|
|
||||||
|
|
||||||
### 2. Email Obfuscation
|
|
||||||
|
|
||||||
Email addresses can now be obfuscated to prevent automated scraping:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createObfuscatedMailto } from '@/lib/email-obfuscate';
|
|
||||||
import { ObfuscatedEmail } from '@/components/ObfuscatedEmail';
|
|
||||||
|
|
||||||
// React component
|
|
||||||
<ObfuscatedEmail email="contact@dk0.dev">Contact Me</ObfuscatedEmail>
|
|
||||||
|
|
||||||
// HTML string
|
|
||||||
const mailtoLink = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
|
|
||||||
```
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
- Emails are base64 encoded in the HTML
|
|
||||||
- JavaScript decodes them on click
|
|
||||||
- Prevents simple regex-based email scrapers
|
|
||||||
- Still functional for real users
|
|
||||||
|
|
||||||
### 3. URL Obfuscation
|
|
||||||
|
|
||||||
Sensitive URLs can be obfuscated:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createObfuscatedLink } from '@/lib/email-obfuscate';
|
|
||||||
|
|
||||||
const link = createObfuscatedLink('https://sensitive-url.com', 'Click Here');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Rate Limiting
|
|
||||||
|
|
||||||
All API endpoints have rate limiting:
|
|
||||||
- Prevents brute force attacks
|
|
||||||
- Protects against DDoS
|
|
||||||
- Configurable per endpoint
|
|
||||||
|
|
||||||
## Code Obfuscation
|
|
||||||
|
|
||||||
**Note**: Full code obfuscation for Next.js is **not recommended** because:
|
|
||||||
|
|
||||||
1. **Next.js already minifies code** in production builds
|
|
||||||
2. **Obfuscation breaks source maps** (harder to debug)
|
|
||||||
3. **Performance impact** (slower execution)
|
|
||||||
4. **Not effective** - determined attackers can still reverse engineer
|
|
||||||
5. **Maintenance burden** - harder to debug issues
|
|
||||||
|
|
||||||
**Better alternatives:**
|
|
||||||
- ✅ Minification (already enabled in Next.js)
|
|
||||||
- ✅ Environment variables for secrets
|
|
||||||
- ✅ Server-side rendering (code not exposed)
|
|
||||||
- ✅ API authentication
|
|
||||||
- ✅ Rate limiting
|
|
||||||
- ✅ Security headers
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### For Email Protection:
|
|
||||||
1. Use obfuscated emails in public HTML
|
|
||||||
2. Use contact forms instead of direct mailto links
|
|
||||||
3. Monitor for spam patterns
|
|
||||||
|
|
||||||
### For API Protection:
|
|
||||||
1. Always require authentication for sensitive endpoints
|
|
||||||
2. Use rate limiting
|
|
||||||
3. Log suspicious activity
|
|
||||||
4. Use HTTPS only
|
|
||||||
5. Validate all inputs
|
|
||||||
|
|
||||||
### For Webhook Protection:
|
|
||||||
1. Use secret tokens (`N8N_SECRET_TOKEN`)
|
|
||||||
2. Verify webhook signatures
|
|
||||||
3. Rate limit webhook endpoints
|
|
||||||
4. Monitor webhook usage
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
- ✅ n8n endpoints protected with auth + rate limiting
|
|
||||||
- ✅ Email obfuscation utility created
|
|
||||||
- ✅ URL obfuscation utility created
|
|
||||||
- ✅ Rate limiting on all n8n endpoints
|
|
||||||
- ⚠️ Email obfuscation not yet applied to pages (manual step)
|
|
||||||
- ⚠️ Code obfuscation not implemented (not recommended)
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
To apply email obfuscation to your pages:
|
|
||||||
|
|
||||||
1. Import the utility:
|
|
||||||
```typescript
|
|
||||||
import { ObfuscatedEmail } from '@/lib/email-obfuscate';
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Replace email links:
|
|
||||||
```tsx
|
|
||||||
// Before
|
|
||||||
<a href="mailto:contact@dk0.dev">Contact</a>
|
|
||||||
|
|
||||||
// After
|
|
||||||
<ObfuscatedEmail email="contact@dk0.dev">Contact</ObfuscatedEmail>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For static HTML, use the string function:
|
|
||||||
```typescript
|
|
||||||
const html = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
|
|
||||||
```
|
|
||||||
42
SESSION_SUMMARY.md
Normal file
42
SESSION_SUMMARY.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Session Summary - February 16, 2026
|
||||||
|
|
||||||
|
## 🛡️ Security & Technical Fixes
|
||||||
|
- **CSP Improvements:** Added `images.unsplash.com`, `*.dk0.dev`, and `localhost` to `img-src` and `connect-src`.
|
||||||
|
- **Worker Support:** Enabled `worker-src 'self' blob:;` for dynamic features.
|
||||||
|
- **Source Map Suppression:** Configured Webpack to ignore 404 errors for `framer-motion` and `LayoutGroupContext` source maps in development.
|
||||||
|
- **Project Filtering:** Unified the projects API to use Directus as the "Single Source of Truth," strictly enforcing the `published` status.
|
||||||
|
|
||||||
|
## 🎨 UI/UX Enhancements (Liquid Editorial Bento)
|
||||||
|
- **Hero Section:**
|
||||||
|
- Stabilized the hero photo (removed floating animation).
|
||||||
|
- Fixed edge-clipping by increasing the border/padding.
|
||||||
|
- Removed redundant social buttons for a cleaner entry.
|
||||||
|
- **Activity Feed:**
|
||||||
|
- Full localization (DE/EN).
|
||||||
|
- Added a rotating cycle of CS-related quotes (Dijkstra, etc.) including CMS quotes.
|
||||||
|
- Redesigned Music UI with Spotify-themed branding (`#1DB954`), improved contrast, and animated frequency bars.
|
||||||
|
- **Contact Area:**
|
||||||
|
- Redesigned into a unified "Connect" Bento box.
|
||||||
|
- High-typography list style for Email, GitHub, LinkedIn, and Location.
|
||||||
|
- **Hobbies:**
|
||||||
|
- Added personalized descriptions reflecting interests like Analog Photography, Astronomy, and Traveling.
|
||||||
|
- Switched to a 4-column layout for better spatial balance.
|
||||||
|
|
||||||
|
## 🚀 New Features
|
||||||
|
- **Snippets System ("The Lab"):**
|
||||||
|
- New Directus collection and API endpoint for technical notes.
|
||||||
|
- Interactive Bento-modals with code syntax highlighting and copy-to-clipboard functionality.
|
||||||
|
- Dedicated `/snippets` overview page.
|
||||||
|
- Implemented "Featured" logic to control visibility on the home page.
|
||||||
|
- **Redesigned 404 Page:**
|
||||||
|
- Completely rebuilt in the Editorial Bento style with clear navigation paths.
|
||||||
|
- **Visual Finish:**
|
||||||
|
- Added a subtle, animated CSS-based Grain/Noise overlay.
|
||||||
|
- Implemented smooth Page Transitions using Framer Motion.
|
||||||
|
|
||||||
|
## 💻 Hardware Setup ("My Gear")
|
||||||
|
- Added a dedicated Bento card showing current dev setup:
|
||||||
|
- MacBook Pro M4 Pro (24GB RAM).
|
||||||
|
- PC: Ryzen 7 3800XT / RTX 3080.
|
||||||
|
- Server: IONOS Cloud & Raspberry Pi 4.
|
||||||
|
- Dual MSI 164Hz Curved Monitors.
|
||||||
28
TODO.md
Normal file
28
TODO.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Portfolio Roadmap
|
||||||
|
|
||||||
|
## Completed ✅
|
||||||
|
|
||||||
|
- [x] **Dark Mode Support**: `next-themes` integration, `ThemeToggle` component, and dark mode styles.
|
||||||
|
- [x] **Performance**: Replaced `<img>` with Next.js `<Image>` for optimization.
|
||||||
|
- [x] **SEO**: Added JSON-LD Structured Data for projects.
|
||||||
|
- [x] **Security**: Rate limiting added to `book-reviews`, `hobbies`, and `tech-stack` APIs.
|
||||||
|
- [x] **Book Reviews**:
|
||||||
|
- `ReadBooks` component updated to handle optional ratings/reviews.
|
||||||
|
- `CurrentlyReading` component verified.
|
||||||
|
- Automation guide created (`docs/N8N_HARDCOVER_GUIDE.md`).
|
||||||
|
- [x] **Testing**: Added tests for `book-reviews`, `hobbies`, `tech-stack`, `CurrentlyReading`, and `ThemeToggle`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Directus CMS
|
||||||
|
- [ ] **Messages Collection**: Create `messages` collection in Directus for dynamic i18n (currently using `messages/*.json`).
|
||||||
|
- [ ] **Projects Migration**: Finish migrating projects content to Directus (script exists: `scripts/migrate-projects-to-directus.js`).
|
||||||
|
- [ ] **Webhooks**: Configure Directus webhooks for On-Demand ISR Revalidation.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [ ] **Blog/Articles**: Design and implement the blog section.
|
||||||
|
- [ ] **Project Detail Gallery**: Add a lightbox/gallery for project screenshots.
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
- [ ] **GitHub Actions**: Migrate CI/CD fully to GitHub Actions (from Gitea).
|
||||||
|
- [ ] **Docker Optimization**: Further reduce image size.
|
||||||
100
app/[locale]/books/page.tsx
Normal file
100
app/[locale]/books/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Star, ArrowLeft } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { Skeleton } from "@/app/components/ui/Skeleton";
|
||||||
|
import { BookReview } from "@/lib/directus";
|
||||||
|
|
||||||
|
export default function BooksPage() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const [reviews, setReviews] = useState<BookReview[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBooks = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/book-reviews?locale=${locale}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.bookReviews) setReviews(data.bookReviews);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Books fetch failed:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchBooks();
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="mb-20">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
|
<span className="font-bold uppercase tracking-widest text-xs">{locale === 'de' ? 'Zurück' : 'Back Home'}</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
|
Library<span className="text-liquid-purple">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||||
|
{locale === "de"
|
||||||
|
? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben."
|
||||||
|
: "Books that shaped my mindset and expanded my horizons."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
||||||
|
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-8 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
reviews?.map((review) => (
|
||||||
|
<div
|
||||||
|
key={review.id}
|
||||||
|
className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
{review.book_image && (
|
||||||
|
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800">
|
||||||
|
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="flex justify-between items-start gap-4 mb-4">
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3>
|
||||||
|
{review.rating && (
|
||||||
|
<div className="flex items-center gap-1 bg-stone-50 dark:bg-stone-800 px-3 py-1 rounded-full border border-stone-100 dark:border-stone-700">
|
||||||
|
<Star size={12} className="fill-amber-400 text-amber-400" />
|
||||||
|
<span className="text-xs font-black">{review.rating}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 font-bold text-sm mb-6">{review.book_author}</p>
|
||||||
|
{review.review && (
|
||||||
|
<div className="mt-auto pt-6 border-t border-stone-50 dark:border-stone-800">
|
||||||
|
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
|
||||||
|
“{review.review.replace(/<[^>]*>/g, '')}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { setRequestLocale } from "next-intl/server";
|
import { setRequestLocale } from "next-intl/server";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
import ConsentBanner from "../components/ConsentBanner";
|
import ConsentBanner from "../components/ConsentBanner";
|
||||||
import { getLocalizedMessage } from "@/lib/i18n-loader";
|
|
||||||
|
|
||||||
async function loadEnhancedMessages(locale: string) {
|
// Supported locales - must match middleware.ts
|
||||||
|
const SUPPORTED_LOCALES = ["en", "de"] as const;
|
||||||
|
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
|
||||||
|
function isValidLocale(locale: string): locale is SupportedLocale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEnhancedMessages(locale: SupportedLocale) {
|
||||||
// Lade basis JSON Messages
|
// Lade basis JSON Messages
|
||||||
const baseMessages = (await import(`../../messages/${locale}.json`)).default;
|
const baseMessages = (await import(`../../messages/${locale}.json`)).default;
|
||||||
|
|
||||||
@@ -13,6 +21,11 @@ async function loadEnhancedMessages(locale: string) {
|
|||||||
return baseMessages;
|
return baseMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Define valid static params to prevent malicious path traversal
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -21,6 +34,12 @@ export default async function LocaleLayout({
|
|||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
|
// Security: Validate locale to prevent malicious imports
|
||||||
|
if (!isValidLocale(locale)) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure next-intl actually uses the route segment locale for this request.
|
// Ensure next-intl actually uses the route segment locale for this request.
|
||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
// Load messages explicitly by route locale to avoid falling back to the wrong
|
// Load messages explicitly by route locale to avoid falling back to the wrong
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
import { getProjectBySlug } from "@/lib/directus";
|
||||||
|
import { ProjectDetailData } from "@/app/_ui/ProjectDetailClient";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -12,6 +14,20 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ locale: string; slug: string }>;
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
|
||||||
|
// Try Directus first for metadata
|
||||||
|
const directusProject = await getProjectBySlug(slug, locale);
|
||||||
|
if (directusProject) {
|
||||||
|
return {
|
||||||
|
title: directusProject.title,
|
||||||
|
description: directusProject.description,
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||||
|
languages: getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
||||||
return {
|
return {
|
||||||
alternates: {
|
alternates: {
|
||||||
@@ -28,7 +44,8 @@ export default async function ProjectPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
|
||||||
const project = await prisma.project.findFirst({
|
// Try PostgreSQL first
|
||||||
|
const dbProject = await prisma.project.findFirst({
|
||||||
where: { slug, published: true },
|
where: { slug, published: true },
|
||||||
include: {
|
include: {
|
||||||
translations: {
|
translations: {
|
||||||
@@ -37,29 +54,66 @@ export default async function ProjectPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) return notFound();
|
let projectData: ProjectDetailData | null = null;
|
||||||
|
|
||||||
const trPreferred = project.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
if (dbProject) {
|
||||||
const trDefault = project.translations?.find(
|
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||||
(t) => t.locale === project.defaultLocale && (t?.title || t?.description),
|
const trDefault = dbProject.translations?.find(
|
||||||
);
|
(t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description),
|
||||||
const tr = trPreferred ?? trDefault;
|
);
|
||||||
const { translations: _translations, ...rest } = project;
|
const tr = trPreferred ?? trDefault;
|
||||||
const localizedContent = (() => {
|
const { translations: _translations, ...rest } = dbProject;
|
||||||
if (typeof tr?.content === "string") return tr.content;
|
const localizedContent = (() => {
|
||||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
if (typeof tr?.content === "string") return tr.content;
|
||||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||||
if (typeof markdown === "string") return markdown;
|
const markdown = (tr.content as Record<string, unknown>).markdown;
|
||||||
|
if (typeof markdown === "string") return markdown;
|
||||||
|
}
|
||||||
|
return dbProject.content;
|
||||||
|
})();
|
||||||
|
projectData = {
|
||||||
|
...rest,
|
||||||
|
title: tr?.title ?? dbProject.title,
|
||||||
|
description: tr?.description ?? dbProject.description,
|
||||||
|
content: localizedContent,
|
||||||
|
} as ProjectDetailData;
|
||||||
|
} else {
|
||||||
|
// Try Directus fallback
|
||||||
|
const directusProject = await getProjectBySlug(slug, locale);
|
||||||
|
if (directusProject) {
|
||||||
|
projectData = {
|
||||||
|
...directusProject,
|
||||||
|
id: typeof directusProject.id === 'string' ? (parseInt(directusProject.id) || 0) : directusProject.id,
|
||||||
|
} as ProjectDetailData;
|
||||||
}
|
}
|
||||||
return project.content;
|
}
|
||||||
})();
|
|
||||||
const localized = {
|
if (!projectData) return notFound();
|
||||||
...rest,
|
|
||||||
title: tr?.title ?? project.title,
|
const jsonLd = {
|
||||||
description: tr?.description ?? project.description,
|
"@context": "https://schema.org",
|
||||||
content: localizedContent,
|
"@type": "SoftwareSourceCode",
|
||||||
|
"name": projectData.title,
|
||||||
|
"description": projectData.description,
|
||||||
|
"codeRepository": projectData.github_url || projectData.github,
|
||||||
|
"programmingLanguage": projectData.technologies,
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Dennis Konkol"
|
||||||
|
},
|
||||||
|
"dateCreated": projectData.date || projectData.created_at,
|
||||||
|
"url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||||
|
"image": (projectData.imageUrl || projectData.image_url) ? toAbsoluteUrl((projectData.imageUrl || projectData.image_url)!) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ProjectDetailClient project={localized} locale={locale} />;
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ProjectDetailClient project={projectData} locale={locale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
import { getProjects as getDirectusProjects } from "@/lib/directus";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ export default async function ProjectsPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
const projects = await prisma.project.findMany({
|
// Fetch from PostgreSQL
|
||||||
|
const dbProjects = await prisma.project.findMany({
|
||||||
where: { published: true },
|
where: { published: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
@@ -37,20 +39,56 @@ export default async function ProjectsPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const localized = projects.map((p) => {
|
// Fetch from Directus
|
||||||
|
let directusProjects: ProjectListItem[] = [];
|
||||||
|
try {
|
||||||
|
const fetched = await getDirectusProjects(locale, { published: true });
|
||||||
|
if (fetched) {
|
||||||
|
directusProjects = fetched.map(p => ({
|
||||||
|
...p,
|
||||||
|
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||||
|
})) as ProjectListItem[];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Directus projects fetch failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
|
||||||
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||||
const trDefault = p.translations?.find(
|
const trDefault = p.translations?.find(
|
||||||
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
||||||
);
|
);
|
||||||
const tr = trPreferred ?? trDefault;
|
const tr = trPreferred ?? trDefault;
|
||||||
const { translations: _translations, ...rest } = p;
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
id: p.id,
|
||||||
|
slug: p.slug,
|
||||||
title: tr?.title ?? p.title,
|
title: tr?.title ?? p.title,
|
||||||
description: tr?.description ?? p.description,
|
description: tr?.description ?? p.description,
|
||||||
|
tags: p.tags,
|
||||||
|
category: p.category,
|
||||||
|
date: p.date,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return <ProjectsPageClient projects={localized} locale={locale} />;
|
// Merge projects, prioritizing DB ones if slugs match
|
||||||
|
const allProjects: ProjectListItem[] = [...localizedDb];
|
||||||
|
const dbSlugs = new Set(localizedDb.map(p => p.slug));
|
||||||
|
|
||||||
|
for (const dp of directusProjects) {
|
||||||
|
if (!dbSlugs.has(dp.slug)) {
|
||||||
|
allProjects.push(dp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final sort by date
|
||||||
|
allProjects.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.date || a.createdAt || 0).getTime();
|
||||||
|
const dateB = new Date(b.date || b.createdAt || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <ProjectsPageClient projects={allProjects} locale={locale} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
app/[locale]/snippets/SnippetsClient.tsx
Normal file
109
app/[locale]/snippets/SnippetsClient.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Snippet } from "@/lib/directus";
|
||||||
|
import { X, Copy, Check, Hash } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
|
||||||
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyToClipboard = (code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{initialSnippets.map((s, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={s.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
onClick={() => setSelectedSnippet(s)}
|
||||||
|
className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-stone-50 dark:bg-stone-800 flex items-center justify-center text-stone-400 group-hover:text-liquid-purple transition-colors">
|
||||||
|
<Hash size={16} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">{s.category}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-4 group-hover:text-liquid-purple transition-colors">{s.title}</h3>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed">
|
||||||
|
{s.description}
|
||||||
|
</p>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snippet Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedSnippet && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<div className="p-8 md:p-10 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-start mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
|
||||||
|
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
|
||||||
|
{selectedSnippet.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative group/code">
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||||
|
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||||
|
title="Copy Code"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
||||||
|
<code>{selectedSnippet.code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Close Laboratory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/[locale]/snippets/page.tsx
Normal file
41
app/[locale]/snippets/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { getSnippets } from "@/lib/directus";
|
||||||
|
import { Terminal, ArrowLeft } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import SnippetsClient from "./SnippetsClient";
|
||||||
|
|
||||||
|
export default async function SnippetsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const snippets = await getSnippets(100) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 transition-colors duration-500">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-all mb-12 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
|
Back to Portfolio
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-20">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900">
|
||||||
|
<Terminal size={24} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
||||||
|
The Lab<span className="text-liquid-purple">.</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-2xl leading-relaxed">
|
||||||
|
A collection of technical snippets, configurations, and mental notes from my daily building process.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SnippetsClient initialSnippets={snippets} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/__tests__/api/book-reviews.test.tsx
Normal file
20
app/__tests__/api/book-reviews.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/book-reviews/route";
|
||||||
|
|
||||||
|
// Mock the route handler module
|
||||||
|
jest.mock("@/app/api/book-reviews/route", () => ({
|
||||||
|
GET: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GET /api/book-reviews", () => {
|
||||||
|
it("should return book reviews", async () => {
|
||||||
|
(GET as jest.Mock).mockResolvedValue(
|
||||||
|
NextResponse.json({ bookReviews: [{ id: 1, book_title: "Test" }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET({} as NextRequest);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.bookReviews).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
app/__tests__/api/hobbies.test.tsx
Normal file
20
app/__tests__/api/hobbies.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/hobbies/route";
|
||||||
|
|
||||||
|
// Mock the route handler module
|
||||||
|
jest.mock("@/app/api/hobbies/route", () => ({
|
||||||
|
GET: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GET /api/hobbies", () => {
|
||||||
|
it("should return hobbies", async () => {
|
||||||
|
(GET as jest.Mock).mockResolvedValue(
|
||||||
|
NextResponse.json({ hobbies: [{ id: 1, title: "Gaming" }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET({} as NextRequest);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.hobbies).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
app/__tests__/api/tech-stack.test.tsx
Normal file
20
app/__tests__/api/tech-stack.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/tech-stack/route";
|
||||||
|
|
||||||
|
// Mock the route handler module
|
||||||
|
jest.mock("@/app/api/tech-stack/route", () => ({
|
||||||
|
GET: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GET /api/tech-stack", () => {
|
||||||
|
it("should return tech stack", async () => {
|
||||||
|
(GET as jest.Mock).mockResolvedValue(
|
||||||
|
NextResponse.json({ techStack: [{ id: 1, name: "Frontend" }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET({} as NextRequest);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.techStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,13 +58,14 @@ 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');
|
||||||
|
|
||||||
// In the actual code, we use String(data.gaming.name || '')
|
// In the actual code, we use String(data.gaming.name || '')
|
||||||
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
||||||
const nanName = String(NaN || '');
|
const nanValue = NaN;
|
||||||
|
const nanName = String(nanValue || '');
|
||||||
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
||||||
expect(typeof nanName).toBe('string');
|
expect(typeof nanName).toBe('string');
|
||||||
});
|
});
|
||||||
|
|||||||
52
app/__tests__/components/CurrentlyReading.test.tsx
Normal file
52
app/__tests__/components/CurrentlyReading.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// Mock next-intl completely to avoid ESM issues
|
||||||
|
jest.mock("next-intl", () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
useLocale: () => "en",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/image
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CurrentlyReading Component", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skeleton when loading", () => {
|
||||||
|
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
|
||||||
|
const { container } = render(<CurrentlyReadingComp />);
|
||||||
|
expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a book when data is fetched", async () => {
|
||||||
|
const mockBooks = [
|
||||||
|
{
|
||||||
|
title: "Test Book",
|
||||||
|
authors: ["Test Author"],
|
||||||
|
image: "/test.jpg",
|
||||||
|
progress: 50,
|
||||||
|
startedAt: "2024-01-01"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ currentlyReading: mockBooks }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CurrentlyReadingComp />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test Book")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Test Author")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,34 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Header from '@/app/components/Header';
|
import Header from '@/app/components/Header';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
// Mock next-intl
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'en',
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
home: 'Home',
|
||||||
|
about: 'About',
|
||||||
|
projects: 'Projects',
|
||||||
|
contact: 'Contact'
|
||||||
|
};
|
||||||
|
return messages[key] || key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: () => '/en',
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
it('renders the header', () => {
|
it('renders the header with the dk logo', () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
expect(screen.getByText('dk')).toBeInTheDocument();
|
expect(screen.getByText('dk')).toBeInTheDocument();
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
|
|
||||||
const aboutButtons = screen.getAllByText('About');
|
// Check for navigation links (appear in both desktop and mobile menus)
|
||||||
expect(aboutButtons.length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('About').length).toBeGreaterThan(0);
|
||||||
const projectsButtons = screen.getAllByText('Projects');
|
expect(screen.getAllByText('Projects').length).toBeGreaterThan(0);
|
||||||
expect(projectsButtons.length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Contact').length).toBeGreaterThan(0);
|
||||||
|
|
||||||
const contactButtons = screen.getAllByText('Contact');
|
|
||||||
expect(contactButtons.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it('renders the mobile header', () => {
|
|
||||||
render(<Header />);
|
|
||||||
// Check for mobile menu button (hamburger icon)
|
|
||||||
const menuButton = screen.getByLabelText('Open menu');
|
|
||||||
expect(menuButton).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,12 +1,60 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Hero from '@/app/components/Hero';
|
import Hero from '@/app/components/Hero';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
// Mock next-intl/server
|
||||||
|
jest.mock('next-intl/server', () => ({
|
||||||
|
getTranslations: () => Promise.resolve((key: string) => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
badge: 'Student & Self-Hoster',
|
||||||
|
line1: 'Building',
|
||||||
|
line2: 'Stuff.',
|
||||||
|
description: 'Dennis is a student and passionate self-hoster.',
|
||||||
|
ctaWork: 'View My Work',
|
||||||
|
ctaContact: 'Get in touch',
|
||||||
|
};
|
||||||
|
return messages[key] || key;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/image
|
||||||
|
interface ImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
fill?: boolean;
|
||||||
|
priority?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock('next/image', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
data-fill={fill?.toString()}
|
||||||
|
data-priority={priority?.toString()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Hero', () => {
|
describe('Hero', () => {
|
||||||
it('renders the hero section', () => {
|
it('renders the hero section correctly', async () => {
|
||||||
render(<Hero />);
|
const HeroResolved = await Hero({ locale: 'en' });
|
||||||
expect(screen.getByText('Dennis Konkol')).toBeInTheDocument();
|
render(HeroResolved);
|
||||||
expect(screen.getByText(/Student and passionate/i)).toBeInTheDocument();
|
|
||||||
|
// Check for the main headlines (defaults in Hero.tsx)
|
||||||
|
expect(screen.getByText('Building')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Stuff.')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the description from our mock
|
||||||
|
expect(screen.getByText(/Dennis is a student/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the image
|
||||||
expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
|
expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for CTA
|
||||||
|
expect(screen.getByText('View My Work')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
19
app/__tests__/components/ThemeToggle.test.tsx
Normal file
19
app/__tests__/components/ThemeToggle.test.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||||
|
|
||||||
|
// Mock custom ThemeProvider
|
||||||
|
jest.mock("@/app/components/ThemeProvider", () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
theme: "light",
|
||||||
|
setTheme: jest.fn(),
|
||||||
|
}),
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ThemeToggle Component", () => {
|
||||||
|
it("renders the theme toggle button", () => {
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
// Initial render should have the button
|
||||||
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import NotFound from '@/app/not-found';
|
import NotFound from '@/app/not-found';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
// Mock next/navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
back: jest.fn(),
|
||||||
|
push: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next-intl
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'en',
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('NotFound', () => {
|
describe('NotFound', () => {
|
||||||
it('renders the 404 page', () => {
|
it('renders the 404 page with the new design text', () => {
|
||||||
render(<NotFound />);
|
render(<NotFound />);
|
||||||
expect(screen.getByText("Oops! The page you're looking for doesn't exist.")).toBeInTheDocument();
|
expect(screen.getByText(/Page not/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type ActivityFeedComponent = React.ComponentType<Record<string, never>>;
|
|
||||||
|
|
||||||
export default function ActivityFeedClient() {
|
|
||||||
const [Comp, setComp] = useState<ActivityFeedComponent | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const mod = await import("../components/ActivityFeed");
|
|
||||||
const C = (mod as unknown as { default?: ActivityFeedComponent }).default;
|
|
||||||
if (!cancelled && typeof C === "function") {
|
|
||||||
setComp(() => C);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!Comp) return null;
|
|
||||||
return <Comp />;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -5,9 +5,14 @@ import Projects from "../components/Projects";
|
|||||||
import Contact from "../components/Contact";
|
import Contact from "../components/Contact";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import ActivityFeedClient from "./ActivityFeedClient";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Force scroll to top on mount to prevent starting at lower sections
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Script
|
<Script
|
||||||
@@ -32,15 +37,14 @@ export default function HomePage() {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActivityFeedClient />
|
|
||||||
<Header />
|
<Header />
|
||||||
{/* Spacer to prevent navbar overlap */}
|
{/* Spacer to prevent navbar overlap */}
|
||||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
|
||||||
<main className="relative">
|
<main className="relative">
|
||||||
<Hero />
|
<Hero locale="en" />
|
||||||
|
|
||||||
{/* Wavy Separator 1 - Hero to About */}
|
{/* Wavy Separator 1 - Hero to About */}
|
||||||
<div className="relative h-24 overflow-hidden">
|
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
viewBox="0 0 1440 120"
|
viewBox="0 0 1440 120"
|
||||||
@@ -63,7 +67,7 @@ export default function HomePage() {
|
|||||||
<About />
|
<About />
|
||||||
|
|
||||||
{/* Wavy Separator 2 - About to Projects */}
|
{/* Wavy Separator 2 - About to Projects */}
|
||||||
<div className="relative h-24 overflow-hidden">
|
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
viewBox="0 0 1440 120"
|
viewBox="0 0 1440 120"
|
||||||
@@ -86,7 +90,7 @@ export default function HomePage() {
|
|||||||
<Projects />
|
<Projects />
|
||||||
|
|
||||||
{/* Wavy Separator 3 - Projects to Contact */}
|
{/* Wavy Separator 3 - Projects to Contact */}
|
||||||
<div className="relative h-24 overflow-hidden">
|
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||||
<svg
|
<svg
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
viewBox="0 0 1440 120"
|
viewBox="0 0 1440 120"
|
||||||
|
|||||||
@@ -1,15 +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 ActivityFeedClient from "./ActivityFeedClient";
|
|
||||||
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,
|
||||||
@@ -21,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),
|
||||||
@@ -54,12 +52,11 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActivityFeedClient />
|
|
||||||
<Header locale={locale} />
|
<Header locale={locale} />
|
||||||
{/* 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">
|
||||||
@@ -82,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">
|
||||||
@@ -105,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">
|
||||||
@@ -128,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
|
||||||
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export type ProjectDetailData = {
|
export type ProjectDetailData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,10 +16,16 @@ export type ProjectDetailData = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
date: string;
|
date?: string;
|
||||||
|
created_at?: string;
|
||||||
github?: string | null;
|
github?: string | null;
|
||||||
|
github_url?: string | null;
|
||||||
live?: string | null;
|
live?: string | null;
|
||||||
|
button_live_label?: string | null;
|
||||||
|
button_github_label?: string | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
technologies?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectDetailClient({
|
export default function ProjectDetailClient({
|
||||||
@@ -31,213 +37,134 @@ export default function ProjectDetailClient({
|
|||||||
}) {
|
}) {
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tDetail = useTranslations("projects.detail");
|
const tDetail = useTranslations("projects.detail");
|
||||||
const tShared = useTranslations("projects.shared");
|
const router = useRouter();
|
||||||
|
const [canGoBack, setCanGoBack] = useState(false);
|
||||||
|
|
||||||
// Track page view (non-blocking)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
// Prüfen, ob wir eine History haben (von Home gekommen)
|
||||||
navigator.sendBeacon?.(
|
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||||
"/api/analytics/track",
|
setCanGoBack(true);
|
||||||
new Blob(
|
|
||||||
[
|
|
||||||
JSON.stringify({
|
|
||||||
type: "pageview",
|
|
||||||
projectId: project.id.toString(),
|
|
||||||
page: `/${locale}/projects/${project.slug}`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{ type: "application/json" },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}, [project.id, project.slug, locale]);
|
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBack = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Wenn wir direkt auf die Seite gekommen sind (Deep Link), gehen wir zur Projektliste
|
||||||
|
// Ansonsten nutzen wir den Browser-Back, um an die exakte Stelle der Home oder Liste zurückzukehren
|
||||||
|
if (canGoBack) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push(`/${locale}/projects`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Navigation */}
|
|
||||||
<motion.div
|
{/* Navigation - Intelligent Back */}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<button
|
||||||
animate={{ opacity: 1, y: 0 }}
|
onClick={handleBack}
|
||||||
transition={{ duration: 0.6 }}
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
|
||||||
className="mb-8"
|
|
||||||
>
|
>
|
||||||
<Link
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
href={`/${locale}/projects`}
|
<span className="font-bold uppercase tracking-widest text-xs">
|
||||||
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-900 transition-colors group"
|
{tCommon("back")}
|
||||||
>
|
</span>
|
||||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
</button>
|
||||||
<span className="font-medium">{tCommon("backToProjects")}</span>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Header & Meta */}
|
{/* Title Section */}
|
||||||
<motion.div
|
<div className="mb-20">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{project.title}<span className="text-liquid-mint">.</span>
|
||||||
transition={{ duration: 0.8, delay: 0.1 }}
|
</h1>
|
||||||
className="mb-12"
|
<p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
|
||||||
>
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-black font-sans text-stone-900 tracking-tight leading-tight">
|
|
||||||
{project.title}
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-2 shrink-0 pt-2">
|
|
||||||
{project.featured && (
|
|
||||||
<span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
|
|
||||||
{tShared("featured")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="px-4 py-1.5 bg-white border border-stone-200 text-stone-600 text-xs font-medium rounded-full shadow-sm">
|
|
||||||
{project.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xl md:text-2xl text-stone-600 font-light leading-relaxed max-w-3xl mb-8">
|
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-6 text-stone-500 text-sm border-y border-stone-200 py-6">
|
{/* Feature Image Box */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-4 md:p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm mb-12 overflow-hidden">
|
||||||
<Calendar size={18} />
|
<div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
|
||||||
<span className="font-mono">
|
{project.imageUrl ? (
|
||||||
{new Date(project.date).toLocaleDateString(locale || undefined, {
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
|
||||||
year: "numeric",
|
) : (
|
||||||
month: "long",
|
<div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
|
||||||
day: "numeric",
|
<span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||||
})}
|
</div>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-stone-300 hidden sm:block"></div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{project.tags.map((tag) => (
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
<span key={tag} className="text-stone-700 font-medium">
|
<div className="lg:col-span-8 space-y-8">
|
||||||
#{tag}
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
|
||||||
</span>
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
))}
|
<ReactMarkdown>{project.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Featured Image / Fallback */}
|
<div className="lg:col-span-4 space-y-8">
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
|
|
||||||
>
|
|
||||||
{project.imageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={project.imageUrl} alt={project.title} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-200 to-stone-300 flex items-center justify-center">
|
|
||||||
<span className="text-9xl font-serif font-bold text-stone-500/20 select-none">
|
|
||||||
{project.title.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Content & Sidebar Layout */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
|
||||||
{/* Main Content */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
|
||||||
className="lg:col-span-2"
|
|
||||||
>
|
|
||||||
<div className="markdown prose prose-stone max-w-none prose-lg prose-headings:font-bold prose-headings:tracking-tight prose-a:text-stone-900 prose-a:decoration-stone-300 hover:prose-a:decoration-stone-900 prose-img:rounded-xl prose-img:shadow-lg">
|
|
||||||
<ReactMarkdown
|
|
||||||
components={{
|
|
||||||
h1: ({ children }) => (
|
|
||||||
<h1 className="text-3xl font-bold text-stone-900 mt-8 mb-4">{children}</h1>
|
|
||||||
),
|
|
||||||
h2: ({ children }) => (
|
|
||||||
<h2 className="text-2xl font-bold text-stone-900 mt-8 mb-4">{children}</h2>
|
|
||||||
),
|
|
||||||
p: ({ children }) => <p className="text-stone-700 leading-relaxed mb-6">{children}</p>,
|
|
||||||
li: ({ children }) => <li className="text-stone-700">{children}</li>,
|
|
||||||
code: ({ children }) => (
|
|
||||||
<code className="bg-stone-100 text-stone-800 px-1.5 py-0.5 rounded text-sm font-mono font-medium">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
),
|
|
||||||
pre: ({ children }) => (
|
|
||||||
<pre className="bg-stone-900 text-stone-50 p-6 rounded-xl overflow-x-auto my-6 shadow-lg">
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Sidebar / Actions */}
|
{/* Quick Links Box - Only show if links exist */}
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
|
||||||
className="lg:col-span-1 space-y-8"
|
|
||||||
>
|
|
||||||
<div className="bg-white/50 backdrop-blur-xl border border-white/60 p-6 rounded-2xl shadow-sm sticky top-32">
|
|
||||||
<h3 className="font-bold text-stone-900 mb-4 flex items-center gap-2">
|
|
||||||
<Share2 size={18} />
|
|
||||||
{tDetail("links")}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{project.live && project.live.trim() && project.live !== "#" ? (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-between w-full px-4 py-3 bg-stone-900 text-stone-50 rounded-xl font-medium hover:bg-stone-800 hover:scale-[1.02] transition-all shadow-md group"
|
|
||||||
>
|
|
||||||
<span>{tDetail("liveDemo")}</span>
|
|
||||||
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-3 bg-stone-100 text-stone-400 rounded-xl font-medium text-sm text-center border border-stone-200 cursor-not-allowed">
|
|
||||||
{tDetail("liveNotAvailable")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.github && project.github.trim() && project.github !== "#" ? (
|
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-between w-full px-4 py-3 bg-white border border-stone-200 text-stone-700 rounded-xl font-medium hover:bg-stone-50 hover:text-stone-900 hover:border-stone-300 transition-all shadow-sm group"
|
|
||||||
>
|
|
||||||
<span>{tDetail("viewSource")}</span>
|
|
||||||
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-stone-100">
|
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
|
||||||
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">{tDetail("techStack")}</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
|
||||||
{project.tags.map((tag) => (
|
|
||||||
<span
|
<div className="space-y-4">
|
||||||
key={tag}
|
|
||||||
className="px-2.5 py-1 bg-stone-100 text-stone-600 text-xs font-medium rounded-md border border-stone-200"
|
{project.live && project.live !== "#" && (
|
||||||
>
|
|
||||||
{tag}
|
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black hover:scale-105 transition-transform group">
|
||||||
</span>
|
|
||||||
))}
|
<span>{project.button_live_label || tDetail("liveDemo")}</span>
|
||||||
</div>
|
|
||||||
|
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform" />
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.github && project.github !== "#" && (
|
||||||
|
|
||||||
|
<a href={project.github} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-stone-800 text-white border border-stone-700 rounded-2xl font-black hover:bg-stone-700 transition-colors group">
|
||||||
|
|
||||||
|
<span>{project.button_github_label || tDetail("viewSource")}</span>
|
||||||
|
|
||||||
|
<GithubIcon size={20} className="group-hover:rotate-12 transition-transform" />
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
|
||||||
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-stone-400">Stack</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span key={tag} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
|
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Skeleton } from "../components/ui/Skeleton";
|
||||||
|
|
||||||
export type ProjectListItem = {
|
export type ProjectListItem = {
|
||||||
id: number;
|
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
|
||||||
tags: string[];
|
tags: string[];
|
||||||
featured: boolean;
|
|
||||||
category: string;
|
category: string;
|
||||||
date: string;
|
date?: string;
|
||||||
github?: string | null;
|
createdAt?: string;
|
||||||
live?: string | null;
|
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,14 +29,15 @@ export default function ProjectsPageClient({
|
|||||||
}) {
|
}) {
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tList = useTranslations("projects.list");
|
const tList = useTranslations("projects.list");
|
||||||
const tShared = useTranslations("projects.shared");
|
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mounted, setMounted] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
// Simulate initial load for smoother entrance or handle actual fetch if needed
|
||||||
|
const timer = setTimeout(() => setLoading(false), 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
@@ -47,248 +47,111 @@ export default function ProjectsPageClient({
|
|||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
let result = projects;
|
let result = projects;
|
||||||
|
|
||||||
if (selectedCategory !== "all") {
|
if (selectedCategory !== "all") {
|
||||||
result = result.filter((project) => project.category === selectedCategory);
|
result = result.filter((project) => project.category === selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(project) =>
|
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
|
||||||
project.title.toLowerCase().includes(query) ||
|
|
||||||
project.description.toLowerCase().includes(query) ||
|
|
||||||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [projects, selectedCategory, searchQuery]);
|
}, [projects, selectedCategory, searchQuery]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<div className="mb-24">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
className="mb-12"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>{tCommon("backToHome")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
{tList("title")}
|
Archive<span className="text-liquid-mint">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
|
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||||
</motion.div>
|
{tList("intro")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters & Search */}
|
{/* Filters */}
|
||||||
<motion.div
|
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
className="mb-12 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center"
|
|
||||||
>
|
|
||||||
{/* Categories */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categories.map((category) => (
|
{categories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={cat}
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => setSelectedCategory(cat)}
|
||||||
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
|
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
|
||||||
selectedCategory === category
|
selectedCategory === cat
|
||||||
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md"
|
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
|
||||||
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
|
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category === "all" ? tList("all") : category}
|
{cat === 'all' ? tList('all') : cat}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
{/* Search */}
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||||
<div className="relative w-full md:w-64">
|
<input
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
type="text"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={tList("searchPlaceholder")}
|
placeholder={tList("searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
|
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-all shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Projects Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{filteredProjects.map((project, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={project.id}
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
|
||||||
whileHover={{ y: -8 }}
|
|
||||||
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
|
|
||||||
>
|
|
||||||
{/* Image / Fallback / Cover Area */}
|
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
|
|
||||||
{project.imageUrl ? (
|
|
||||||
<>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={project.imageUrl}
|
|
||||||
alt={project.title}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
|
|
||||||
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
|
|
||||||
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
|
|
||||||
{project.title.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Texture/Grain Overlay */}
|
|
||||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
|
||||||
|
|
||||||
{/* Animated Shine Effect */}
|
|
||||||
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
|
|
||||||
|
|
||||||
{project.featured && (
|
|
||||||
<div className="absolute top-3 left-3 z-20">
|
|
||||||
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
|
|
||||||
{tShared("featured")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay Links */}
|
|
||||||
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="GitHub"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="Live Demo"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 flex flex-col flex-1">
|
|
||||||
{/* Stretched Link covering the whole card (including image area) */}
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/projects/${project.slug}`}
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
aria-label={`View project ${project.title}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
|
|
||||||
<Calendar size={12} />
|
|
||||||
<span>{new Date(project.date).getFullYear()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">{project.description}</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{project.tags.slice(0, 4).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{project.tags.length > 4 && (
|
|
||||||
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredProjects.length === 0 && (
|
{/* Grid */}
|
||||||
<div className="text-center py-20">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<p className="text-stone-500 text-lg">{tList("noResults")}</p>
|
{loading ? (
|
||||||
<button
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
onClick={() => {
|
<div key={i} className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
||||||
setSelectedCategory("all");
|
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
|
||||||
setSearchQuery("");
|
<div className="space-y-3">
|
||||||
}}
|
<Skeleton className="h-8 w-1/2" />
|
||||||
className="mt-4 text-stone-800 font-medium hover:underline"
|
<Skeleton className="h-4 w-3/4" />
|
||||||
>
|
</div>
|
||||||
{tList("clearFilters")}
|
</div>
|
||||||
</button>
|
))
|
||||||
</div>
|
) : (
|
||||||
)}
|
filteredProjects.map((project) => (
|
||||||
|
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||||
|
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
|
||||||
|
{project.imageUrl && (
|
||||||
|
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
||||||
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all">
|
||||||
|
<ArrowUpRight size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
|
||||||
|
<div className="mt-auto flex flex-wrap gap-2">
|
||||||
|
{project.tags.slice(0, 3).map(tag => (
|
||||||
|
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
56
app/api/book-reviews/route.ts
Normal file
56
app/api/book-reviews/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getBookReviews } from '@/lib/directus';
|
||||||
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/book-reviews
|
||||||
|
*
|
||||||
|
* Loads Book Reviews from Directus CMS
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - locale: en or de (default: en)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate Limit: 60 requests per minute
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 60, 60000)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
|
const reviews = await getBookReviews(locale);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`[API] Book Reviews geladen für ${locale}:`, reviews?.length || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviews && reviews.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ bookReviews: reviews, source: 'directus' },
|
||||||
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ bookReviews: null, source: 'fallback' },
|
||||||
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('Error loading book reviews:', error);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ bookReviews: null, error: 'Failed to load book reviews', source: 'error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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)} · ${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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 →
|
||||||
|
</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 · <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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getHobbies } from '@/lib/directus';
|
import { getHobbies } from '@/lib/directus';
|
||||||
|
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
|
||||||
@@ -13,6 +16,12 @@ export const dynamic = 'force-dynamic';
|
|||||||
* - locale: en or de (default: en)
|
* - locale: en or de (default: en)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate Limit: 60 requests per minute
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 60, 60000)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const locale = searchParams.get('locale') || 'en';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
@@ -21,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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flatten das Objekt zu flachen Keys
|
// Flatten das Objekt zu flachen Keys
|
||||||
const flatKeys = flattenObject(namespaceData);
|
const flatKeys = flattenObject(namespaceData as Record<string, unknown>);
|
||||||
|
|
||||||
// Lade jeden Key aus Directus (mit Fallback auf JSON)
|
// Lade jeden Key aus Directus (mit Fallback auf JSON)
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
@@ -57,19 +57,24 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Holt verschachtelte Werte aus Objekt
|
// Helper: Holt verschachtelte Werte aus Objekt
|
||||||
function getNestedValue(obj: any, path: string): any {
|
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
return path.split('.').reduce<unknown>((current, key) => {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
return (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Flatten verschachteltes Objekt zu flachen Keys
|
// Helper: Flatten verschachteltes Objekt zu flachen Keys
|
||||||
function flattenObject(obj: any, prefix = ''): Record<string, string> {
|
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
Object.assign(result, flattenObject(value, newKey));
|
Object.assign(result, flattenObject(value as Record<string, unknown>, newKey));
|
||||||
} else {
|
} else {
|
||||||
result[newKey] = String(value);
|
result[newKey] = String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,19 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
import { getMessages } from "@/lib/directus";
|
||||||
import enMessages from '@/messages/en.json';
|
|
||||||
import deMessages from '@/messages/de.json';
|
|
||||||
|
|
||||||
// Cache für 5 Minuten
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
export const revalidate = 300;
|
|
||||||
|
|
||||||
const messagesMap = { en: enMessages, de: deMessages };
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
/**
|
const locale = searchParams.get("locale") || "en";
|
||||||
* GET /api/messages?locale=en
|
|
||||||
* Lädt ALLE Messages aus Directus + JSON Fallback
|
|
||||||
* Wird von next-intl als messages source verwendet
|
|
||||||
*/
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const locale = req.nextUrl.searchParams.get('locale') || 'en';
|
|
||||||
|
|
||||||
// Normalize locale (de-DE -> de)
|
|
||||||
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Starte mit JSON als Basis
|
const messages = await getMessages(locale);
|
||||||
const jsonMessages = messagesMap[normalizedLocale as 'en' | 'de'];
|
return NextResponse.json(
|
||||||
|
{ messages },
|
||||||
// Clone das Objekt
|
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
const messages = JSON.parse(JSON.stringify(jsonMessages));
|
|
||||||
|
|
||||||
// Flatten alle Keys
|
|
||||||
const allKeys = getAllKeys(messages);
|
|
||||||
|
|
||||||
// Lade jeden Key aus Directus (überschreibt JSON wenn vorhanden)
|
|
||||||
await Promise.all(
|
|
||||||
allKeys.map(async (key) => {
|
|
||||||
try {
|
|
||||||
const value = await getLocalizedMessage(key, locale);
|
|
||||||
if (value && value !== key) {
|
|
||||||
// Überschreibe den Wert im messages Objekt
|
|
||||||
setNestedValue(messages, key, value);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback auf JSON Wert (schon vorhanden)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
} catch {
|
||||||
return NextResponse.json(messages, {
|
return NextResponse.json({ messages: {} }, { status: 500 });
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Messages API error:', error);
|
|
||||||
// Fallback: Return nur JSON messages
|
|
||||||
return NextResponse.json(messagesMap[normalizedLocale as 'en' | 'de'], {
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'public, s-maxage=60',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Sammle alle Keys aus verschachteltem Objekt
|
|
||||||
function getAllKeys(obj: any, prefix = ''): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
||||||
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
keys.push(...getAllKeys(value, fullKey));
|
|
||||||
} else {
|
|
||||||
keys.push(fullKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Setze Wert in verschachteltem Objekt
|
|
||||||
function setNestedValue(obj: any, path: string, value: any) {
|
|
||||||
const keys = path.split('.');
|
|
||||||
const lastKey = keys.pop()!;
|
|
||||||
|
|
||||||
let current = obj;
|
|
||||||
for (const key of keys) {
|
|
||||||
if (!(key in current)) {
|
|
||||||
current[key] = {};
|
|
||||||
}
|
|
||||||
current = current[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
current[lastKey] = value;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
125
app/api/n8n/hardcover/sync-books/route.ts
Normal file
125
app/api/n8n/hardcover/sync-books/route.ts
Normal 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 // 1–5
|
||||||
|
* 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 });
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp }
|
|||||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||||
import { generateUniqueSlug } from '@/lib/slug';
|
import { generateUniqueSlug } from '@/lib/slug';
|
||||||
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
||||||
|
import { ProjectListItem } from '@/app/_ui/ProjectsPageClient';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -41,87 +42,80 @@ export async function GET(request: NextRequest) {
|
|||||||
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
||||||
const category = searchParams.get('category');
|
const category = searchParams.get('category');
|
||||||
const featured = searchParams.get('featured');
|
const featured = searchParams.get('featured');
|
||||||
const published = searchParams.get('published');
|
const published = searchParams.get('published') === 'false' ? false : true; // Default to true if not specified
|
||||||
const difficulty = searchParams.get('difficulty');
|
const difficulty = searchParams.get('difficulty');
|
||||||
const search = searchParams.get('search');
|
const search = searchParams.get('search');
|
||||||
const locale = searchParams.get('locale') || 'en';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
// Try Directus FIRST (Primary Source)
|
// Try Directus FIRST (Primary Source)
|
||||||
|
let directusProjects: ProjectListItem[] = [];
|
||||||
|
let directusSuccess = false;
|
||||||
try {
|
try {
|
||||||
const directusProjects = await getDirectusProjects(locale, {
|
const fetched = await getDirectusProjects(locale, {
|
||||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
published: published,
|
||||||
category: category || undefined,
|
category: category || undefined,
|
||||||
difficulty: difficulty || undefined,
|
difficulty: difficulty || undefined,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
limit
|
limit
|
||||||
});
|
});
|
||||||
|
|
||||||
if (directusProjects && directusProjects.length > 0) {
|
if (fetched) {
|
||||||
return NextResponse.json({
|
directusProjects = fetched.map(p => ({
|
||||||
projects: directusProjects,
|
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||||
total: directusProjects.length,
|
slug: p.slug,
|
||||||
page: 1,
|
title: p.title,
|
||||||
limit: directusProjects.length,
|
description: p.description,
|
||||||
source: 'directus'
|
tags: p.tags || [],
|
||||||
});
|
category: p.category || '',
|
||||||
|
date: p.created_at,
|
||||||
|
createdAt: p.created_at,
|
||||||
|
imageUrl: p.image_url,
|
||||||
|
}));
|
||||||
|
directusSuccess = true;
|
||||||
}
|
}
|
||||||
} catch (directusError) {
|
} catch {
|
||||||
console.log('Directus not available, trying PostgreSQL fallback');
|
console.log('Directus error, continuing with PostgreSQL fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback 1: Try PostgreSQL
|
// If Directus returned projects, use them EXCLUSIVELY to avoid showing un-synced local data
|
||||||
try {
|
if (directusSuccess && directusProjects.length > 0) {
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
} catch (dbError) {
|
|
||||||
console.log('PostgreSQL also not available, using empty fallback');
|
|
||||||
|
|
||||||
// Fallback 2: Return empty (components should have hardcoded fallback)
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
projects: [],
|
projects: directusProjects,
|
||||||
total: 0,
|
total: directusProjects.length,
|
||||||
page: 1,
|
source: 'directus'
|
||||||
limit,
|
|
||||||
source: 'fallback'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cache parameters object
|
// Fallback 1: Try PostgreSQL only if Directus failed or is empty
|
||||||
const cacheParams = {
|
try {
|
||||||
page: page.toString(),
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
limit: limit.toString(),
|
} catch {
|
||||||
category,
|
console.log('PostgreSQL not available');
|
||||||
featured,
|
return NextResponse.json({
|
||||||
published,
|
projects: directusProjects, // Might be empty
|
||||||
difficulty,
|
total: directusProjects.length,
|
||||||
search
|
source: 'directus-empty'
|
||||||
};
|
});
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = await apiCache.getProjects(cacheParams);
|
|
||||||
if (cached && !search) { // Don't cache search results
|
|
||||||
return NextResponse.json(cached);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (category) where.category = category;
|
if (category) where.category = category;
|
||||||
if (featured !== null) where.featured = featured === 'true';
|
if (featured !== null) where.featured = featured === 'true';
|
||||||
if (published !== null) where.published = published === 'true';
|
where.published = published;
|
||||||
if (difficulty) where.difficulty = difficulty;
|
if (difficulty) where.difficulty = difficulty;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
{ tags: { hasSome: [search] } },
|
{ tags: { hasSome: [search] } }
|
||||||
{ content: { contains: search, mode: 'insensitive' } }
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projects, total] = await Promise.all([
|
const [dbProjects, total] = await Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -131,20 +125,31 @@ export async function GET(request: NextRequest) {
|
|||||||
prisma.project.count({ where })
|
prisma.project.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = {
|
// Merge logic
|
||||||
projects,
|
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
||||||
total,
|
const mergedProjects: ProjectListItem[] = dbProjects.map(p => ({
|
||||||
pages: Math.ceil(total / limit),
|
id: p.id,
|
||||||
currentPage: page,
|
slug: p.slug,
|
||||||
source: 'postgresql'
|
title: p.title,
|
||||||
};
|
description: p.description,
|
||||||
|
tags: p.tags,
|
||||||
// Cache the result (only for non-search queries)
|
category: p.category,
|
||||||
if (!search) {
|
date: p.date,
|
||||||
await apiCache.setProjects(cacheParams, result);
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const dp of directusProjects) {
|
||||||
|
if (!dbSlugs.has(dp.slug)) {
|
||||||
|
mergedProjects.push(dp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json({
|
||||||
|
projects: mergedProjects,
|
||||||
|
total: total + (mergedProjects.length - dbProjects.length),
|
||||||
|
source: 'merged'
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle missing database table gracefully
|
// Handle missing database table gracefully
|
||||||
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { getProjects } from '@/lib/directus';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -7,56 +7,27 @@ export async function GET(request: NextRequest) {
|
|||||||
const slug = searchParams.get('slug');
|
const slug = searchParams.get('slug');
|
||||||
const search = searchParams.get('search');
|
const search = searchParams.get('search');
|
||||||
const category = searchParams.get('category');
|
const category = searchParams.get('category');
|
||||||
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
|
// Use Directus instead of Prisma
|
||||||
|
const projects = await getProjects(locale, {
|
||||||
|
featured: undefined,
|
||||||
|
published: true,
|
||||||
|
category: category && category !== 'All' ? category : undefined,
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!projects) {
|
||||||
|
// Directus not available or no projects found
|
||||||
|
return NextResponse.json({ projects: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by slug if provided (since Directus query doesn't support slug filter directly)
|
||||||
if (slug) {
|
if (slug) {
|
||||||
const project = await prisma.project.findFirst({
|
const project = projects.find(p => p.slug === slug);
|
||||||
where: {
|
|
||||||
published: true,
|
|
||||||
slug,
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ projects: project ? [project] : [] });
|
return NextResponse.json({ projects: project ? [project] : [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
|
||||||
// General search
|
|
||||||
const projects = await prisma.project.findMany({
|
|
||||||
where: {
|
|
||||||
published: true,
|
|
||||||
OR: [
|
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
|
||||||
{ tags: { hasSome: [search] } },
|
|
||||||
{ content: { contains: search, mode: 'insensitive' } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ projects });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category && category !== 'All') {
|
|
||||||
// Filter by category
|
|
||||||
const projects = await prisma.project.findMany({
|
|
||||||
where: {
|
|
||||||
published: true,
|
|
||||||
category: category
|
|
||||||
},
|
|
||||||
orderBy: { createdAt: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ projects });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return all published projects if no specific search
|
|
||||||
const projects = await prisma.project.findMany({
|
|
||||||
where: { published: true },
|
|
||||||
orderBy: { createdAt: 'desc' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ projects });
|
return NextResponse.json({ projects });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching projects:', error);
|
console.error('Error searching projects:', error);
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
21
app/api/snippets/route.ts
Normal file
21
app/api/snippets/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSnippets } from '@/lib/directus';
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
|
const featured = searchParams.get('featured') === 'true' ? true : undefined;
|
||||||
|
|
||||||
|
const snippets = await getSnippets(limit, featured);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ snippets: snippets || [] },
|
||||||
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
|
);
|
||||||
|
} catch (_error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getTechStack } from '@/lib/directus';
|
import { getTechStack } from '@/lib/directus';
|
||||||
|
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)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate Limit: 60 requests per minute
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 60, 60000)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const locale = searchParams.get('locale') || 'en';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
@@ -21,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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,397 +1,361 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, Variants } from "framer-motion";
|
import { useState, useEffect } from "react";
|
||||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
|
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import dynamic from "next/dynamic";
|
||||||
import RichTextClient from "./RichTextClient";
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
import CurrentlyReading from "./CurrentlyReading";
|
import CurrentlyReading from "./CurrentlyReading";
|
||||||
|
import ReadBooks from "./ReadBooks";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { TechStackCategory, TechStackItem, Hobby, Snippet } from "@/lib/directus";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ActivityFeed from "./ActivityFeed";
|
||||||
|
import BentoChat from "./BentoChat";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
|
import { LucideIcon, X, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
// Type definitions for CMS data
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
interface TechStackItem {
|
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
||||||
id: string;
|
|
||||||
name: string | number | null | undefined;
|
|
||||||
url?: string;
|
|
||||||
icon_url?: string;
|
|
||||||
sort: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TechStackCategory {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
icon: string;
|
|
||||||
sort: number;
|
|
||||||
name: string;
|
|
||||||
items: TechStackItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Hobby {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
icon: string;
|
|
||||||
title: string | number | null | undefined;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const staggerContainer: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.15,
|
|
||||||
delayChildren: 0.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeInUp: Variants = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const 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 [techStackFromCMS, setTechStackFromCMS] = useState<TechStackCategory[] | null>(null);
|
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||||
const [hobbiesFromCMS, setHobbiesFromCMS] = useState<Hobby[] | null>(null);
|
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||||
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
|
||||||
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`,
|
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
||||||
);
|
fetch(`/api/tech-stack?locale=${locale}`),
|
||||||
const data = await res.json();
|
fetch(`/api/hobbies?locale=${locale}`),
|
||||||
// Only use CMS content if it exists for the active locale.
|
fetch(`/api/messages?locale=${locale}`),
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
fetch(`/api/snippets?limit=3&featured=true`)
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
]);
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
const cmsData = await cmsRes.json();
|
||||||
}
|
if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
|
||||||
} catch {
|
|
||||||
// ignore; fallback to static
|
const techData = await techRes.json();
|
||||||
setCmsDoc(null);
|
if (techData?.techStack) setTechStack(techData.techStack);
|
||||||
}
|
|
||||||
})();
|
const hobbiesData = await hobbiesRes.json();
|
||||||
}, [locale]);
|
if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies);
|
||||||
|
|
||||||
// Load Tech Stack from Directus
|
const msgData = await msgRes.json();
|
||||||
useEffect(() => {
|
if (msgData?.messages) setCmsMessages(msgData.messages);
|
||||||
(async () => {
|
|
||||||
try {
|
const snippetsData = await snippetsRes.json();
|
||||||
const res = await fetch(`/api/tech-stack?locale=${encodeURIComponent(locale)}`);
|
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (data?.techStack && data.techStack.length > 0) {
|
|
||||||
setTechStackFromCMS(data.techStack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
console.error("About data fetch failed:", error);
|
||||||
console.log('Tech Stack from Directus not available, using fallback');
|
} finally {
|
||||||
}
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
fetchData();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
// Load Hobbies from Directus
|
const copyToClipboard = (code: string) => {
|
||||||
useEffect(() => {
|
navigator.clipboard.writeText(code);
|
||||||
(async () => {
|
setCopied(true);
|
||||||
try {
|
setTimeout(() => setCopied(false), 2000);
|
||||||
const res = await fetch(`/api/hobbies?locale=${encodeURIComponent(locale)}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (data?.hobbies && data.hobbies.length > 0) {
|
|
||||||
setHobbiesFromCMS(data.hobbies);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('Hobbies from Directus not available, using fallback');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
// Fallback Tech Stack (from messages/en.json, messages/de.json)
|
|
||||||
const techStackFallback = [
|
|
||||||
{
|
|
||||||
key: 'frontend',
|
|
||||||
category: t("techStack.categories.frontendMobile"),
|
|
||||||
icon: Globe,
|
|
||||||
items: ["Next.js", "Tailwind CSS", "Flutter"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'backend',
|
|
||||||
category: t("techStack.categories.backendDevops"),
|
|
||||||
icon: Server,
|
|
||||||
items: ["Docker Swarm", "Traefik", "Nginx Proxy Manager", "Redis"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tools',
|
|
||||||
category: t("techStack.categories.toolsAutomation"),
|
|
||||||
icon: Wrench,
|
|
||||||
items: ["Git", "CI/CD", "n8n", t("techStack.items.selfHostedServices")],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'security',
|
|
||||||
category: t("techStack.categories.securityAdmin"),
|
|
||||||
icon: Shield,
|
|
||||||
items: ["CrowdSec", "Suricata", "Mailcow"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map icon names from Directus to Lucide components
|
|
||||||
const iconMap: Record<string, any> = {
|
|
||||||
Globe,
|
|
||||||
Server,
|
|
||||||
Code,
|
|
||||||
Wrench,
|
|
||||||
Shield,
|
|
||||||
Activity,
|
|
||||||
Lightbulb,
|
|
||||||
Gamepad2
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback Hobbies
|
|
||||||
const hobbiesFallback: Array<{ icon: typeof Code; text: string }> = [
|
|
||||||
{ icon: Code, text: t("hobbies.selfHosting") },
|
|
||||||
{ icon: Gamepad2, text: t("hobbies.gaming") },
|
|
||||||
{ icon: Server, text: t("hobbies.gameServers") },
|
|
||||||
{ icon: Activity, text: t("hobbies.jogging") },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use CMS Hobbies if available, otherwise fallback
|
|
||||||
const hobbies = hobbiesFromCMS
|
|
||||||
? hobbiesFromCMS
|
|
||||||
.map((hobby: Hobby) => {
|
|
||||||
// Convert to string, handling NaN/null/undefined
|
|
||||||
const text = hobby.title == null || (typeof hobby.title === 'number' && isNaN(hobby.title))
|
|
||||||
? ''
|
|
||||||
: String(hobby.title);
|
|
||||||
return {
|
|
||||||
icon: iconMap[hobby.icon] || Code,
|
|
||||||
text
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(h => {
|
|
||||||
const isValid = h.text.trim().length > 0;
|
|
||||||
if (!isValid && process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[About] Filtered out invalid hobby:', h);
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
})
|
|
||||||
: hobbiesFallback;
|
|
||||||
|
|
||||||
// Use CMS Tech Stack if available, otherwise fallback
|
|
||||||
const techStack = techStackFromCMS
|
|
||||||
? techStackFromCMS.map((cat: TechStackCategory) => {
|
|
||||||
const items = cat.items
|
|
||||||
.map((item: TechStackItem) => {
|
|
||||||
// Convert to string, handling NaN/null/undefined
|
|
||||||
if (item.name == null || (typeof item.name === 'number' && isNaN(item.name))) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[About] Invalid item.name in category', cat.key, ':', item);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return String(item.name);
|
|
||||||
})
|
|
||||||
.filter(name => {
|
|
||||||
const isValid = name.trim().length > 0;
|
|
||||||
if (!isValid && process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[About] Filtered out empty item name in category', cat.key);
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0 && process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('[About] Category has no valid items after filtering:', cat.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: cat.key,
|
|
||||||
category: cat.name,
|
|
||||||
icon: iconMap[cat.icon] || Code,
|
|
||||||
items
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: techStackFallback;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="about" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
id="about"
|
<div className="max-w-7xl mx-auto">
|
||||||
className="py-24 px-4 bg-gradient-to-br from-liquid-sky/15 via-liquid-lavender/10 to-liquid-pink/15 relative overflow-hidden"
|
|
||||||
>
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||||
<div className="max-w-6xl mx-auto relative z-10">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
|
{/* 1. Large Bio Text */}
|
||||||
{/* Text Content */}
|
<motion.div
|
||||||
<motion.div
|
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"
|
||||||
initial="hidden"
|
|
||||||
whileInView="visible"
|
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
variants={staggerContainer}
|
|
||||||
className="space-y-8"
|
|
||||||
>
|
>
|
||||||
<motion.h2
|
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
||||||
variants={fadeInUp}
|
<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">
|
||||||
className="text-4xl md:text-5xl font-bold text-stone-900"
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
>
|
</h2>
|
||||||
{t("title")}
|
<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">
|
||||||
</motion.h2>
|
{isLoading ? (
|
||||||
<motion.div
|
<div className="space-y-3">
|
||||||
variants={fadeInUp}
|
<Skeleton className="h-6 w-full" />
|
||||||
className="prose prose-stone prose-lg text-stone-700 space-y-4"
|
<Skeleton className="h-6 w-[95%]" />
|
||||||
>
|
<Skeleton className="h-6 w-[90%]" />
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>{t("p1")}</p>
|
|
||||||
<p>{t("p2")}</p>
|
|
||||||
<p>{t("p3")}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<motion.div
|
|
||||||
variants={fadeInUp}
|
|
||||||
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-lavender/15 border-2 border-liquid-mint/30 rounded-xl p-5 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Lightbulb size={20} className="text-stone-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-stone-800 mb-1">
|
|
||||||
{t("funFactTitle")}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-stone-700 leading-relaxed">
|
|
||||||
{t("funFactBody")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : cmsHtml ? (
|
||||||
|
<RichTextClient html={cmsHtml} />
|
||||||
|
) : (
|
||||||
|
<p>{t("p1")} {t("p2")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 sm:pt-6 md:pt-8">
|
||||||
|
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
|
||||||
|
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Tech Stack & Hobbies */}
|
{/* 2. Activity / Status Box */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial="hidden"
|
transition={{ delay: 0.1 }}
|
||||||
whileInView="visible"
|
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"
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
|
||||||
variants={staggerContainer}
|
|
||||||
className="space-y-8"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="relative z-10 h-full">
|
||||||
<motion.h3
|
<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">
|
||||||
variants={fadeInUp}
|
<Activity size={20} /> Status
|
||||||
className="text-2xl font-bold text-stone-900 mb-6"
|
</h3>
|
||||||
>
|
<ActivityFeed locale={locale} />
|
||||||
{t("techStackTitle")}
|
</div>
|
||||||
</motion.h3>
|
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
||||||
<div className="grid grid-cols-1 gap-4">
|
</motion.div>
|
||||||
{techStack.map((stack, idx) => (
|
|
||||||
<motion.div
|
{/* 3. AI Chat Box */}
|
||||||
key={`${stack.category}-${idx}`}
|
<motion.div
|
||||||
variants={fadeInUp}
|
transition={{ delay: 0.2 }}
|
||||||
whileHover={{
|
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"
|
||||||
scale: 1.02,
|
>
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
<div className="flex items-center gap-2 mb-5 sm:mb-8">
|
||||||
}}
|
<MessageSquare className="text-liquid-purple" size={20} />
|
||||||
className={`p-5 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
|
||||||
idx === 0
|
</div>
|
||||||
? "bg-gradient-to-br from-liquid-sky/25 to-liquid-mint/25 border-liquid-sky/50 hover:border-liquid-sky/70 hover:from-liquid-sky/35 hover:to-liquid-mint/35"
|
<div className="flex-1">
|
||||||
: idx === 1
|
<BentoChat />
|
||||||
? "bg-gradient-to-br from-liquid-peach/25 to-liquid-coral/25 border-liquid-peach/50 hover:border-liquid-peach/70 hover:from-liquid-peach/35 hover:to-liquid-coral/35"
|
</div>
|
||||||
: idx === 2
|
</motion.div>
|
||||||
? "bg-gradient-to-br from-liquid-lavender/25 to-liquid-pink/25 border-liquid-lavender/50 hover:border-liquid-lavender/70 hover:from-liquid-lavender/35 hover:to-liquid-pink/35"
|
|
||||||
: "bg-gradient-to-br from-liquid-teal/25 to-liquid-lime/25 border-liquid-teal/50 hover:border-liquid-teal/70 hover:from-liquid-teal/35 hover:to-liquid-lime/35"
|
{/* 4. Tech Stack */}
|
||||||
}`}
|
<motion.div
|
||||||
>
|
transition={{ delay: 0.3 }}
|
||||||
<div className="flex items-center gap-3 mb-3">
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
<div className="p-2 bg-white rounded-lg shadow-sm text-stone-700">
|
>
|
||||||
<stack.icon size={18} />
|
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 md:gap-12">
|
||||||
</div>
|
{isLoading ? (
|
||||||
<h4 className="font-semibold text-stone-800">
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
{stack.category}
|
<div key={i} className="space-y-6">
|
||||||
</h4>
|
<Skeleton className="h-3 w-20" />
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stack.items.map((item, itemIdx) => (
|
<Skeleton className="h-8 w-24 rounded-xl" />
|
||||||
<span
|
<Skeleton className="h-8 w-16 rounded-xl" />
|
||||||
key={`${stack.category}-${item}-${itemIdx}`}
|
<Skeleton className="h-8 w-20 rounded-xl" />
|
||||||
className={`px-3 py-1.5 rounded-lg border-2 text-sm text-stone-800 font-semibold transition-all duration-400 ease-out backdrop-blur-sm ${
|
</div>
|
||||||
itemIdx % 4 === 0
|
</div>
|
||||||
? "bg-liquid-mint/25 border-liquid-mint/50 hover:bg-liquid-mint/35 hover:border-liquid-mint/70"
|
))
|
||||||
: itemIdx % 4 === 1
|
) : (
|
||||||
? "bg-liquid-lavender/25 border-liquid-lavender/50 hover:bg-liquid-lavender/35 hover:border-liquid-lavender/70"
|
techStack.map((cat) => (
|
||||||
: itemIdx % 4 === 2
|
<div key={cat.id} className="space-y-6">
|
||||||
? "bg-liquid-rose/25 border-liquid-rose/50 hover:bg-liquid-rose/35 hover:border-liquid-rose/70"
|
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
|
||||||
: "bg-liquid-sky/25 border-liquid-sky/50 hover:bg-liquid-sky/35 hover:border-liquid-sky/70"
|
<div className="flex flex-wrap gap-2">
|
||||||
}`}
|
{cat.items?.map((item: TechStackItem) => (
|
||||||
>
|
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
|
||||||
{String(item)}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hobbies */}
|
|
||||||
<div>
|
|
||||||
<motion.h3
|
|
||||||
variants={fadeInUp}
|
|
||||||
className="text-xl font-bold text-stone-900 mb-4"
|
|
||||||
>
|
|
||||||
{t("hobbiesTitle")}
|
|
||||||
</motion.h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{hobbies.map((hobby, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={`hobby-${hobby.text}-${idx}`}
|
|
||||||
variants={fadeInUp}
|
|
||||||
whileHover={{
|
|
||||||
x: 8,
|
|
||||||
scale: 1.02,
|
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${
|
|
||||||
idx === 0
|
|
||||||
? "bg-gradient-to-r from-liquid-mint/25 to-liquid-sky/25 border-liquid-mint/50 hover:border-liquid-mint/70 hover:from-liquid-mint/35 hover:to-liquid-sky/35"
|
|
||||||
: idx === 1
|
|
||||||
? "bg-gradient-to-r from-liquid-coral/25 to-liquid-peach/25 border-liquid-coral/50 hover:border-liquid-coral/70 hover:from-liquid-coral/35 hover:to-liquid-peach/35"
|
|
||||||
: idx === 2
|
|
||||||
? "bg-gradient-to-r from-liquid-lavender/25 to-liquid-pink/25 border-liquid-lavender/50 hover:border-liquid-lavender/70 hover:from-liquid-lavender/35 hover:to-liquid-pink/35"
|
|
||||||
: "bg-gradient-to-r from-liquid-lime/25 to-liquid-teal/25 border-liquid-lime/50 hover:border-liquid-lime/70 hover:from-liquid-lime/35 hover:to-liquid-teal/35"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<hobby.icon size={20} className="text-stone-700" />
|
|
||||||
<span className="text-stone-800 font-semibold">
|
|
||||||
{String(hobby.text)}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Currently Reading */}
|
|
||||||
<motion.div
|
|
||||||
variants={fadeInUp}
|
|
||||||
className="mt-8"
|
|
||||||
>
|
|
||||||
<CurrentlyReading />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 5. Library, Gear & Snippets */}
|
||||||
|
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||||
|
{/* Library - Larger Span */}
|
||||||
|
<motion.div
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
|
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-10">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter">
|
||||||
|
<BookOpen className="text-liquid-purple" size={24} /> Library
|
||||||
|
</h3>
|
||||||
|
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
||||||
|
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CurrentlyReading />
|
||||||
|
<div className="mt-6 flex-1">
|
||||||
|
<ReadBooks />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
|
||||||
|
{/* My Gear (Uses) */}
|
||||||
|
<motion.div
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black mb-5 sm:mb-8 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter text-white">
|
||||||
|
<Cpu className="text-liquid-mint" size={24} /> My Gear
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:gap-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-2 sm:gap-3 uppercase tracking-tighter mb-4 sm:mb-6">
|
||||||
|
<Terminal className="text-liquid-purple" size={24} /> Snippets
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-12 rounded-xl" />)
|
||||||
|
) : snippets.length > 0 ? (
|
||||||
|
snippets.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setSelectedSnippet(s)}
|
||||||
|
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
|
||||||
|
>
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
||||||
|
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-stone-500 dark:text-stone-400 italic">No snippets yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||||
|
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Hobbies */}
|
||||||
|
<motion.div
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="md:col-span-12"
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 md:gap-6 mb-6 sm:mb-8 md:mb-12">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||||
|
) : (
|
||||||
|
hobbies.map((hobby) => {
|
||||||
|
const Icon = iconMap[hobby.icon] || Lightbulb;
|
||||||
|
return (
|
||||||
|
<div key={hobby.id} className="p-3 sm:p-4 md:p-6 bg-stone-50 dark:bg-stone-800 rounded-xl sm:rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3 mb-1.5 sm:mb-3">
|
||||||
|
<Icon size={16} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0 sm:w-5 sm:h-5" />
|
||||||
|
<h4 className="font-bold text-xs sm:text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] sm:text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed hidden sm:block">
|
||||||
|
{hobby.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 sm:space-y-2 border-t border-stone-100 dark:border-stone-800 pt-4 sm:pt-6 md:pt-8">
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
|
||||||
|
<p className="text-stone-500 font-light text-sm sm:text-base md:text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Snippet Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedSnippet && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<div className="p-5 sm:p-8 md:p-10 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-start mb-5 sm:mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-1 sm:mb-2">{selectedSnippet.category}</p>
|
||||||
|
<h3 className="text-xl sm:text-2xl md:text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm sm:text-base text-stone-600 dark:text-stone-400 mb-5 sm:mb-8 leading-relaxed">
|
||||||
|
{selectedSnippet.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative group/code">
|
||||||
|
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||||
|
className="p-2 sm:p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||||
|
title="Copy Code"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-stone-950 p-4 sm:p-6 rounded-xl sm:rounded-2xl overflow-x-auto text-xs sm:text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
||||||
|
<code>{selectedSnippet.code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Close Laboratory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
114
app/components/BentoChat.tsx
Normal file
114
app/components/BentoChat.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Send, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sender: "user" | "bot";
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sender: "user" | "bot";
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BentoChat() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [conversationId, setConversationId] = useState<string>("default");
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const storedId = localStorage.getItem("chatSessionId");
|
||||||
|
if (storedId) setConversationId(storedId);
|
||||||
|
else {
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
localStorage.setItem("chatSessionId", newId);
|
||||||
|
setConversationId(newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedMsgs = localStorage.getItem("chatMessages");
|
||||||
|
if (storedMsgs) {
|
||||||
|
setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) })));
|
||||||
|
} else {
|
||||||
|
setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) {
|
||||||
|
localStorage.setItem("chatMessages", JSON.stringify(messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputValue.trim() || isLoading) return;
|
||||||
|
const userMsg: Message = { id: Date.now().toString(), text: inputValue.trim(), sender: "user", timestamp: new Date() };
|
||||||
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setInputValue("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/n8n/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: userMsg.text, conversationId, history: messages.slice(-5).map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })) }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: data.reply || "Error", sender: "bot", timestamp: new Date() }]);
|
||||||
|
} catch {
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: "Connection error.", sender: "bot", timestamp: new Date() }]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(true);
|
||||||
|
setTimeout(() => setIsLoading(false), 500); // Small delay for feel
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-[300px]">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4"
|
||||||
|
>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
|
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
placeholder="Ask me..."
|
||||||
|
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
|
||||||
|
/>
|
||||||
|
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,19 +5,13 @@ import { usePathname } from "next/navigation";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ToastProvider } from "@/components/Toast";
|
import { ToastProvider } from "@/components/Toast";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
import { ConsentProvider } from "./ConsentProvider";
|
||||||
import { ConsentProvider, useConsent } from "./ConsentProvider";
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
|
|
||||||
// Dynamic import with SSR disabled to avoid framer-motion issues
|
const BackgroundBlobs = dynamic(
|
||||||
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
|
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
|
||||||
ssr: false,
|
{ ssr: false, loading: () => null }
|
||||||
loading: () => null,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function ClientProviders({
|
export default function ClientProviders({
|
||||||
children,
|
children,
|
||||||
@@ -25,56 +19,21 @@ export default function ClientProviders({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [is404Page, setIs404Page] = useState(false);
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
// Check if we're on a 404 page by looking for the data attribute or pathname
|
|
||||||
const check404 = () => {
|
|
||||||
try {
|
|
||||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
||||||
const has404Component = document.querySelector('[data-404-page]');
|
|
||||||
const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
|
|
||||||
setIs404Page(!!has404Component || is404Path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - 404 detection is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error checking 404 status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Check immediately and after a short delay
|
|
||||||
try {
|
|
||||||
check404();
|
|
||||||
const timeout = setTimeout(check404, 100);
|
|
||||||
const interval = setInterval(check404, 500);
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
clearInterval(interval);
|
|
||||||
} catch {
|
|
||||||
// Silently fail during cleanup
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// If setup fails, just return empty cleanup
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error setting up 404 check:', error);
|
|
||||||
}
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
// Wrap in multiple error boundaries to isolate failures
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConsentProvider>
|
<ConsentProvider>
|
||||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
<ThemeProvider>
|
||||||
{children}
|
<GatedProviders mounted={mounted}>
|
||||||
</GatedProviders>
|
{children}
|
||||||
|
</GatedProviders>
|
||||||
|
</ThemeProvider>
|
||||||
</ConsentProvider>
|
</ConsentProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -84,30 +43,30 @@ export default function ClientProviders({
|
|||||||
function GatedProviders({
|
function GatedProviders({
|
||||||
children,
|
children,
|
||||||
mounted,
|
mounted,
|
||||||
is404Page,
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
is404Page: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { consent } = useConsent();
|
// Defer animated background blobs until after LCP
|
||||||
const pathname = usePathname();
|
const [deferredReady, setDeferredReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
let id: ReturnType<typeof setTimeout> | number;
|
||||||
|
if (typeof requestIdleCallback !== "undefined") {
|
||||||
|
id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 });
|
||||||
|
return () => cancelIdleCallback(id as number);
|
||||||
|
} else {
|
||||||
|
id = setTimeout(() => setDeferredReady(true), 200);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
const isAdminRoute = pathname.startsWith("/manage") || pathname.startsWith("/editor");
|
return (
|
||||||
|
|
||||||
// If consent is not decided yet, treat optional features as off
|
|
||||||
const analyticsEnabled = !!consent?.analytics;
|
|
||||||
const chatEnabled = !!consent?.chat;
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{mounted && <BackgroundBlobs />}
|
{deferredReady && <BackgroundBlobs />}
|
||||||
<div className="relative z-10">{children}</div>
|
<div className="relative z-10">{children}</div>
|
||||||
{mounted && !is404Page && !isAdminRoute && chatEnabled && <ChatWidget />}
|
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +29,7 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
|||||||
return locale.startsWith('de') ? 'de' : 'en';
|
return locale.startsWith('de') ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroClient({ locale, translations }: { locale: string; translations: HeroTranslations }) {
|
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
|
||||||
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, translations }: { locale: string; translations: AboutTranslations }) {
|
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
@@ -61,7 +46,7 @@ export function AboutClient({ locale, translations }: { locale: string; translat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsClient({ locale, translations }: { locale: string; translations: ProjectsTranslations }) {
|
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
@@ -78,7 +63,7 @@ export function ProjectsClient({ locale, translations }: { locale: string; trans
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactClient({ locale, translations }: { locale: string; translations: ContactTranslations }) {
|
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
@@ -95,7 +80,7 @@ export function ContactClient({ locale, translations }: { locale: string; transl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FooterClient({ locale, translations }: { locale: string; translations: FooterTranslations }) {
|
export function FooterClient({ locale }: { locale: string; translations: FooterTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Mail, MapPin, Send } from "lucide-react";
|
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import dynamic from "next/dynamic";
|
||||||
import RichTextClient from "./RichTextClient";
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
const { showEmailSent, showEmailError } = useToast();
|
const { showEmailSent, showEmailError } = useToast();
|
||||||
@@ -14,7 +14,7 @@ const Contact = () => {
|
|||||||
const t = useTranslations("home.contact");
|
const t = useTranslations("home.contact");
|
||||||
const tForm = useTranslations("home.contact.form");
|
const tForm = useTranslations("home.contact.form");
|
||||||
const tInfo = useTranslations("home.contact.info");
|
const tInfo = useTranslations("home.contact.info");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -24,14 +24,14 @@ const Contact = () => {
|
|||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Only use CMS content if it exists for the active locale.
|
// Only use CMS content if it exists for the active locale.
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
} else {
|
} else {
|
||||||
setCmsDoc(null);
|
setCmsHtml(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; fallback to static
|
// ignore; fallback to static
|
||||||
setCmsDoc(null);
|
setCmsHtml(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
@@ -152,118 +152,111 @@ const Contact = () => {
|
|||||||
validateForm();
|
validateForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactInfo = [
|
|
||||||
{
|
|
||||||
icon: Mail,
|
|
||||||
title: tInfo("email"),
|
|
||||||
value: "contact@dk0.dev",
|
|
||||||
href: "mailto:contact@dk0.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MapPin,
|
|
||||||
title: tInfo("location"),
|
|
||||||
value: tInfo("locationValue"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="contact"
|
id="contact"
|
||||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-teal/15 via-liquid-mint/10 to-liquid-lime/15"
|
className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Section Header */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="text-center mb-16"
|
|
||||||
>
|
|
||||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
|
|
||||||
{t("title")}
|
|
||||||
</h2>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
|
|
||||||
) : (
|
|
||||||
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
|
|
||||||
{t("subtitle")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
{/* Header Card */}
|
||||||
{/* Contact Information */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="space-y-8"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="max-w-3xl">
|
||||||
<h3 className="text-2xl font-bold text-stone-900 mb-6">
|
<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("getInTouch")}
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
</h3>
|
</h2>
|
||||||
<p className="text-stone-700 leading-relaxed">
|
{cmsHtml ? (
|
||||||
{t("getInTouchBody")}
|
<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>
|
) : (
|
||||||
</div>
|
<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")}
|
||||||
{/* Contact Details */}
|
</p>
|
||||||
<div className="space-y-4">
|
)}
|
||||||
{contactInfo.map((info, index) => (
|
|
||||||
<motion.a
|
|
||||||
key={info.title}
|
|
||||||
href={info.href}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
delay: index * 0.1,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
}}
|
|
||||||
whileHover={{
|
|
||||||
x: 8,
|
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-[background-color,border-color,box-shadow] duration-500 ease-out group border-transparent hover:border-white/70"
|
|
||||||
>
|
|
||||||
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
|
|
||||||
<info.icon className="w-6 h-6 text-stone-700" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-stone-800">
|
|
||||||
{info.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-stone-500">{info.value}</p>
|
|
||||||
</div>
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Contact Form */}
|
{/* Info Side (Unified Connect Box) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
transition={{ delay: 0.1 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
|
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-gray-800 mb-6">
|
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
||||||
|
{/* Email */}
|
||||||
|
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
|
||||||
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
|
<Mail size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
||||||
|
|
||||||
|
{/* GitHub */}
|
||||||
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
|
||||||
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
|
<Github size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
||||||
|
|
||||||
|
{/* LinkedIn */}
|
||||||
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
|
||||||
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
|
<Linkedin size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
|
||||||
|
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
||||||
|
<MapPin size={14} className="text-liquid-mint" />
|
||||||
|
<span className="font-bold">{tInfo("locationValue")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Form Side */}
|
||||||
|
<motion.div
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
|
>
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-6 sm:mb-8 md:mb-10">
|
||||||
{tForm("title")}
|
{tForm("title")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
htmlFor="name"
|
{tForm("labels.name")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Name <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -273,32 +266,14 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||||
errors.name && touched.name
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.name")}
|
placeholder={tForm("placeholders.name")}
|
||||||
aria-invalid={
|
|
||||||
errors.name && touched.name ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.name && touched.name ? "name-error" : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{errors.name && touched.name && (
|
|
||||||
<p id="name-error" className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
htmlFor="email"
|
{tForm("labels.email")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Email <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -308,33 +283,15 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||||
errors.email && touched.email
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.email")}
|
placeholder={tForm("placeholders.email")}
|
||||||
aria-invalid={
|
|
||||||
errors.email && touched.email ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.email && touched.email ? "email-error" : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{errors.email && touched.email && (
|
|
||||||
<p id="email-error" className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
htmlFor="subject"
|
{tForm("labels.subject")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Subject <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -344,34 +301,14 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||||
errors.subject && touched.subject
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.subject")}
|
placeholder={tForm("placeholders.subject")}
|
||||||
aria-invalid={
|
|
||||||
errors.subject && touched.subject ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.subject && touched.subject
|
|
||||||
? "subject-error"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{errors.subject && touched.subject && (
|
|
||||||
<p id="subject-error" className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.subject}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
htmlFor="message"
|
{tForm("labels.message")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Message <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
@@ -380,53 +317,25 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
rows={6}
|
rows={5}
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all resize-none ${
|
className="w-full px-4 sm:px-6 py-3 sm:py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-xl sm:rounded-2xl text-sm sm:text-base text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
|
||||||
errors.message && touched.message
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.message")}
|
placeholder={tForm("placeholders.message")}
|
||||||
aria-invalid={
|
|
||||||
errors.message && touched.message ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.message && touched.message
|
|
||||||
? "message-error"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-1">
|
|
||||||
{errors.message && touched.message ? (
|
|
||||||
<p id="message-error" className="text-sm text-red-500">
|
|
||||||
{errors.message}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<span></span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-stone-400">
|
|
||||||
{tForm("characters", { count: formData.message.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
|
whileHover={{ scale: 1.01 }}
|
||||||
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
whileTap={{ scale: 0.99 }}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
className="w-full py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] sm:tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
|
||||||
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-stone-950 transition-all duration-500 ease-out shadow-lg hover:shadow-xl"
|
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
|
||||||
<span>{tForm("sending")}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send size={20} />
|
<Send size={16} />
|
||||||
<span className="text-cream">{tForm("send")}</span>
|
{tForm("send")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { motion } from "framer-motion";
|
|||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
|
|
||||||
interface CurrentlyReading {
|
interface CurrentlyReading {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -53,8 +55,26 @@ const CurrentlyReading = () => {
|
|||||||
fetchCurrentlyReading();
|
fetchCurrentlyReading();
|
||||||
}, []); // Leeres Array = nur einmal beim Mount
|
}, []); // Leeres Array = nur einmal beim Mount
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 space-y-3 w-full">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
<Skeleton className="h-2 w-full" />
|
||||||
|
<Skeleton className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
|
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
|
||||||
if (loading || books.length === 0) {
|
if (books.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +82,8 @@ const CurrentlyReading = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<BookOpen size={18} className="text-stone-600 flex-shrink-0" />
|
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
||||||
<h3 className="text-lg font-bold text-stone-900">
|
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
||||||
{t("title")} {books.length > 1 && `(${books.length})`}
|
{t("title")} {books.length > 1 && `(${books.length})`}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,11 +100,11 @@ const CurrentlyReading = () => {
|
|||||||
scale: 1.02,
|
scale: 1.02,
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
}}
|
}}
|
||||||
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 border-2 border-liquid-lavender/30 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
|
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
|
||||||
>
|
>
|
||||||
{/* Background Blob Animation */}
|
{/* Background Blob Animation */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 rounded-full blur-2xl"
|
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
||||||
animate={{
|
animate={{
|
||||||
scale: [1, 1.2, 1],
|
scale: [1, 1.2, 1],
|
||||||
opacity: [0.3, 0.5, 0.3],
|
opacity: [0.3, 0.5, 0.3],
|
||||||
@@ -106,12 +126,13 @@ const CurrentlyReading = () => {
|
|||||||
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50">
|
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
||||||
<img
|
<Image
|
||||||
src={book.image}
|
src={book.image}
|
||||||
alt={book.title}
|
alt={book.title}
|
||||||
className="w-full h-full object-cover"
|
fill
|
||||||
loading="lazy"
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 96px, 112px"
|
||||||
/>
|
/>
|
||||||
{/* Glossy Overlay */}
|
{/* Glossy Overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
||||||
@@ -122,25 +143,25 @@ const CurrentlyReading = () => {
|
|||||||
{/* Book Info */}
|
{/* Book Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h4 className="text-lg font-bold text-stone-900 mb-1 line-clamp-2">
|
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
|
||||||
{book.title}
|
{book.title}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Authors */}
|
{/* Authors */}
|
||||||
<p className="text-sm text-stone-600 mb-4 line-clamp-1">
|
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
|
||||||
{book.authors.join(", ")}
|
{book.authors.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs text-stone-600">
|
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
|
||||||
<span>{t("progress")}</span>
|
<span>{t("progress")}</span>
|
||||||
<span className="font-semibold">{book.progress}%</span>
|
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-2 bg-white/50 rounded-full overflow-hidden border border-white/70">
|
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${book.progress}%` }}
|
animate={{ width: `${isNaN(book.progress) ? 0 : book.progress}%` }}
|
||||||
transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
|
transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
|
||||||
className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
|
className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,142 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
import Link from "next/link";
|
||||||
import { Heart, Code } from 'lucide-react';
|
|
||||||
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useConsent } from "./ConsentProvider";
|
import { ArrowUp } from "lucide-react";
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("footer");
|
const t = useTranslations("footer");
|
||||||
const { resetConsent } = useConsent();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const [currentYear] = useState(() => new Date().getFullYear());
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
const socialLinks = [
|
};
|
||||||
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
|
||||||
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative py-12 px-4 bg-white border-t border-stone-200">
|
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-16 sm:pt-24 md:pt-32 pb-8 sm:pb-12 px-4 sm:px-6 overflow-hidden transition-colors duration-500">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
|
|
||||||
{/* Brand */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 sm:gap-10 md:gap-12 items-end">
|
||||||
<motion.div
|
{/* Copyright & Info */}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<div className="md:col-span-4 space-y-6">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
dk
|
||||||
transition={{ duration: 0.4 }}
|
|
||||||
className="flex items-center space-x-3"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ rotate: 360, scale: 1.1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="w-12 h-12 bg-gradient-to-br from-liquid-mint to-liquid-lavender rounded-xl flex items-center justify-center shadow-md"
|
|
||||||
>
|
|
||||||
<Code className="w-6 h-6 text-stone-800" />
|
|
||||||
</motion.div>
|
|
||||||
<div>
|
|
||||||
<Link href={`/${locale}`} className="text-xl font-bold font-mono text-stone-800 hover:text-liquid-blue transition-colors">
|
|
||||||
dk<span className="text-liquid-rose">0</span>
|
|
||||||
</Link>
|
|
||||||
<p className="text-xs text-stone-500">{t("role")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
<div className="space-y-2">
|
||||||
|
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
|
||||||
|
<p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Social Links */}
|
{/* Navigation Links */}
|
||||||
<motion.div
|
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<div className="space-y-4">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
<div className="flex flex-col gap-2">
|
||||||
transition={{ duration: 0.4, delay: 0.05 }}
|
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
||||||
className="flex space-x-3"
|
<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>
|
||||||
{socialLinks.map((social) => (
|
</div>
|
||||||
<motion.a
|
<div className="space-y-4">
|
||||||
key={social.label}
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
|
||||||
href={social.href}
|
<div className="flex flex-col gap-2">
|
||||||
target="_blank"
|
<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>
|
||||||
rel="noopener noreferrer"
|
<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>
|
||||||
whileHover={{ scale: 1.15, y: -3 }}
|
</div>
|
||||||
whileTap={{ scale: 0.95 }}
|
</div>
|
||||||
className="p-3 bg-stone-50 hover:bg-white rounded-xl text-stone-600 hover:text-stone-900 transition-all duration-200 border border-stone-200 hover:border-stone-300 shadow-sm"
|
</div>
|
||||||
aria-label={social.label}
|
|
||||||
>
|
|
||||||
<social.icon size={18} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Copyright */}
|
{/* Back to Top */}
|
||||||
<motion.div
|
<div className="md:col-span-4 flex justify-start md:justify-end">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<button
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
onClick={scrollToTop}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
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"
|
||||||
transition={{ duration: 0.4, delay: 0.1 }}
|
|
||||||
className="flex items-center space-x-2 text-stone-400 text-sm"
|
|
||||||
>
|
|
||||||
<span>© {currentYear}</span>
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
|
||||||
>
|
>
|
||||||
<Heart size={14} className="text-liquid-rose fill-liquid-rose" />
|
<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>
|
||||||
</motion.div>
|
<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">
|
||||||
<span>{t("madeIn")}</span>
|
<ArrowUp size={20} />
|
||||||
</motion.div>
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legal Links */}
|
{/* Bottom Bar */}
|
||||||
<motion.div
|
<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">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
Built with Next.js, Directus & Passion.
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
</p>
|
||||||
transition={{ duration: 0.4, delay: 0.15 }}
|
<div className="flex items-center gap-2">
|
||||||
className="mt-8 pt-6 border-t border-stone-100 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
>
|
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||||
<div className="flex space-x-6 text-sm">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/legal-notice`}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{t("legalNotice")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/privacy-policy`}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{t("privacyPolicy")}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => resetConsent()}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
title={t("privacySettingsTitle")}
|
|
||||||
>
|
|
||||||
{t("privacySettings")}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/404"
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"
|
|
||||||
title="Kernel Panic 404"
|
|
||||||
>
|
|
||||||
404
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-stone-400 flex items-center space-x-1">
|
|
||||||
<span>{t("builtWith")}</span>
|
|
||||||
<span className="text-stone-600 font-semibold">Next.js</span>
|
|
||||||
<span className="text-stone-300">•</span>
|
|
||||||
<span className="text-stone-600 font-semibold">TypeScript</span>
|
|
||||||
<span className="text-stone-300">•</span>
|
|
||||||
<span className="text-stone-600 font-semibold">Tailwind CSS</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,32 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { Menu, X } from "lucide-react";
|
||||||
import { Menu, X, Mail } from "lucide-react";
|
|
||||||
import { SiGithub, SiLinkedin } from "react-icons/si";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const t = useTranslations("nav");
|
const t = useTranslations("nav");
|
||||||
|
|
||||||
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setScrolled(window.scrollY > 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: t("home"), href: `/${locale}` },
|
{ name: t("home"), href: `/${locale}` },
|
||||||
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
||||||
@@ -34,232 +22,75 @@ const Header = () => {
|
|||||||
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const socialLinks = [
|
|
||||||
{ icon: SiGithub, href: "https://github.com/Denshooter", label: "GitHub" },
|
|
||||||
{
|
|
||||||
icon: SiLinkedin,
|
|
||||||
href: "https://linkedin.com/in/dkonkol",
|
|
||||||
label: "LinkedIn",
|
|
||||||
},
|
|
||||||
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
|
|
||||||
const qs = searchParams.toString();
|
|
||||||
const query = qs ? `?${qs}` : "";
|
|
||||||
const enHref = `/en${pathWithoutLocale}${query}`;
|
|
||||||
const deHref = `/de${pathWithoutLocale}${query}`;
|
|
||||||
|
|
||||||
// Always render to prevent flash, but use opacity transition
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.header
|
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
|
||||||
initial={false}
|
<nav className="animate-slide-down pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4">
|
||||||
animate={{ y: 0, opacity: 1 }}
|
{/* Logo Pill */}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
<Link
|
||||||
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
|
href={`/${locale}`}
|
||||||
>
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
|
||||||
<div
|
|
||||||
className={`pointer-events-auto transition-all duration-500 ease-out ${
|
|
||||||
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
||||||
className={`
|
|
||||||
backdrop-blur-xl transition-all duration-500
|
|
||||||
${
|
|
||||||
scrolled
|
|
||||||
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
|
|
||||||
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
|
|
||||||
}
|
|
||||||
flex justify-between items-center
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<span className="font-black text-xs tracking-tighter">dk</span>
|
||||||
whileHover={{ scale: 1.05 }}
|
</Link>
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
{/* Desktop Menu */}
|
||||||
|
<div className="hidden md:flex items-center gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
key={item.name}
|
||||||
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
|
href={item.href}
|
||||||
|
className="px-5 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-all"
|
||||||
>
|
>
|
||||||
dk<span className="text-red-500">0</span>
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
|
||||||
{navItems.map((item) => (
|
|
||||||
<motion.div
|
|
||||||
key={item.name}
|
|
||||||
whileHover={{ y: -2 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
className="text-stone-600 hover:text-stone-900 transition-colors duration-200 font-medium relative group px-2 py-1 liquid-hover"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (item.href.startsWith("#")) {
|
|
||||||
e.preventDefault();
|
|
||||||
const element = document.querySelector(item.href);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
<motion.span
|
|
||||||
className="absolute -bottom-1 left-0 right-0 h-0.5 bg-gradient-to-r from-liquid-mint to-liquid-lavender rounded-full"
|
|
||||||
initial={{ scaleX: 0, opacity: 0 }}
|
|
||||||
whileHover={{ scaleX: 1, opacity: 1 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.4,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
}}
|
|
||||||
style={{ transformOrigin: "left center" }}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="hidden md:flex items-center space-x-3">
|
{/* Actions Pill */}
|
||||||
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
|
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
|
||||||
<Link
|
<Link
|
||||||
href={enHref}
|
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
|
||||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
className="w-8 h-8 flex items-center justify-center text-[10px] font-black text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
|
||||||
locale === "en"
|
|
||||||
? "bg-stone-900 text-stone-50"
|
|
||||||
: "text-stone-700 hover:bg-white/60"
|
|
||||||
}`}
|
|
||||||
aria-label="Switch language to English"
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={deHref}
|
|
||||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
|
||||||
locale === "de"
|
|
||||||
? "bg-stone-900 text-stone-50"
|
|
||||||
: "text-stone-700 hover:bg-white/60"
|
|
||||||
}`}
|
|
||||||
aria-label="Sprache auf Deutsch umstellen"
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{socialLinks.map((social) => (
|
|
||||||
<motion.a
|
|
||||||
key={social.label}
|
|
||||||
href={social.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
className="p-2 rounded-full bg-white/40 hover:bg-white/80 border border-white/50 text-stone-600 hover:text-stone-900 transition-all shadow-sm liquid-hover"
|
|
||||||
>
|
|
||||||
<social.icon size={18} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<motion.button
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover"
|
|
||||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
|
||||||
>
|
>
|
||||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
{locale === "en" ? "DE" : "EN"}
|
||||||
</motion.button>
|
</Link>
|
||||||
</motion.div>
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* Mobile Menu Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-8 h-8 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-white dark:hover:bg-stone-800 rounded-full transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{isOpen ? <X size={14} /> : <Menu size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
<div
|
||||||
|
className={`fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden transition-all duration-200 ${
|
||||||
|
isOpen
|
||||||
|
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||||
|
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-40 md:hidden pointer-events-auto"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
|
||||||
transition={{ duration: 0.3, type: "spring" }}
|
|
||||||
className="absolute top-24 left-4 right-4 bg-cream/95 backdrop-blur-xl border border-stone-200 shadow-xl rounded-3xl z-50 p-6 pointer-events-auto"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{navItems.map((item, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={item.name}
|
|
||||||
initial={{ x: -20, opacity: 0 }}
|
|
||||||
animate={{ x: 0, opacity: 1 }}
|
|
||||||
exit={{ x: -20, opacity: 0 }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
onClick={(e) => {
|
|
||||||
setIsOpen(false);
|
|
||||||
if (item.href.startsWith("#")) {
|
|
||||||
e.preventDefault();
|
|
||||||
setTimeout(() => {
|
|
||||||
const element = document.querySelector(item.href);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="block text-stone-600 hover:text-stone-900 hover:bg-white/50 transition-all font-medium py-3 px-4 rounded-xl"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="pt-6 mt-4 border-t border-stone-200">
|
|
||||||
<div className="flex justify-center space-x-4">
|
|
||||||
{socialLinks.map((social, index) => (
|
|
||||||
<motion.a
|
|
||||||
key={social.label}
|
|
||||||
href={social.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{
|
|
||||||
delay: (navItems.length + index) * 0.05,
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
className="p-3 rounded-full bg-white/60 text-stone-600 shadow-sm"
|
|
||||||
aria-label={social.label}
|
|
||||||
>
|
|
||||||
<social.icon size={20} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.header>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,253 +1,73 @@
|
|||||||
"use client";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
interface HeroProps {
|
||||||
import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
|
locale: string;
|
||||||
import { useEffect, useState } from "react";
|
}
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import RichTextClient from "./RichTextClient";
|
|
||||||
|
|
||||||
const Hero = () => {
|
export default async function Hero({ locale: _locale }: HeroProps) {
|
||||||
const locale = useLocale();
|
const t = await getTranslations("home.hero");
|
||||||
const t = useTranslations("home.hero");
|
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/api/content/page?key=${encodeURIComponent("home-hero")}&locale=${encodeURIComponent(locale)}`,
|
|
||||||
);
|
|
||||||
const data = await res.json();
|
|
||||||
// Only use CMS content if it exists for the active locale.
|
|
||||||
// If the API falls back to another locale, keep showing next-intl strings
|
|
||||||
// so the locale switch visibly changes the page.
|
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore; fallback to static
|
|
||||||
setCmsDoc(null);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{ icon: Code, text: t("features.f1") },
|
|
||||||
{ icon: Zap, text: t("features.f2") },
|
|
||||||
{ icon: Rocket, text: t("features.f3") },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10">
|
<section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
||||||
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
|
{/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
|
||||||
{/* Profile Image with Organic Blob Mask */}
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
<motion.div
|
<div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
<div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
</div>
|
||||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="mb-12 flex justify-center relative z-20"
|
|
||||||
>
|
|
||||||
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
|
|
||||||
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10"
|
|
||||||
animate={{
|
|
||||||
borderRadius: [
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
],
|
|
||||||
rotate: [0, 120, 0],
|
|
||||||
scale: [1, 1.08, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 35,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10"
|
|
||||||
animate={{
|
|
||||||
borderRadius: [
|
|
||||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
|
||||||
"60% 30% 40% 70%/60% 40% 70% 30%",
|
|
||||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
|
||||||
],
|
|
||||||
rotate: [0, -90, 0],
|
|
||||||
scale: [1, 1.05, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 40,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* The Image Container with Organic Border Radius */}
|
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20 pb-10 sm:pb-16">
|
||||||
<motion.div
|
<div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
|
||||||
className="absolute inset-0 overflow-hidden bg-stone-100"
|
|
||||||
style={{
|
|
||||||
filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))",
|
|
||||||
willChange: "border-radius",
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
borderRadius: [
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 12,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Use a plain <img> to fully bypass Next.js image optimizer (dev 400 issue). */}
|
|
||||||
<img
|
|
||||||
src="/images/me.jpg"
|
|
||||||
alt="Dennis Konkol"
|
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 hover:scale-[1.08] transition-transform duration-1000 ease-out"
|
|
||||||
loading="eager"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Glossy Overlay for Liquid Feel */}
|
{/* Left: Text Content */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/25 via-transparent to-white/10 opacity-60 pointer-events-none z-10" />
|
<div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
|
||||||
|
<div className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm animate-[fadeIn_0.5s_ease-out]">
|
||||||
|
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||||
|
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{t("badge")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Inner Border/Highlight */}
|
<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">
|
||||||
<div className="absolute inset-0 border-[2px] border-white/30 rounded-[inherit] pointer-events-none z-20" />
|
<span className="block">
|
||||||
</motion.div>
|
{t("line1")}
|
||||||
|
|
||||||
{/* Domain Badge - repositioned below image */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
|
|
||||||
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
|
|
||||||
>
|
|
||||||
<div className="px-6 py-2.5 rounded-full bg-white/90 backdrop-blur-xl text-stone-900 font-sans font-bold text-sm tracking-wide shadow-lg border-2 border-stone-300">
|
|
||||||
dk<span className="text-red-500 font-extrabold">0</span>.dev
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Floating Badges - subtle animations */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
|
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
|
||||||
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
|
||||||
>
|
|
||||||
<Code size={24} />
|
|
||||||
</motion.div>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
|
|
||||||
whileHover={{ scale: 1.1, rotate: -5 }}
|
|
||||||
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
|
||||||
>
|
|
||||||
<Zap size={24} />
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Main Title */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="mb-8 flex flex-col items-center justify-center relative"
|
|
||||||
>
|
|
||||||
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2">
|
|
||||||
Dennis Konkol
|
|
||||||
</h1>
|
|
||||||
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 mt-2">
|
|
||||||
Software Engineer
|
|
||||||
</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
|
|
||||||
>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
|
|
||||||
) : (
|
|
||||||
<p>{t("description")}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="flex flex-wrap justify-center gap-4 mb-12"
|
|
||||||
>
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={feature.text}
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
delay: 0.5 + index * 0.1,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.03, y: -3 }}
|
|
||||||
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/85 border-2 border-stone-300 shadow-md backdrop-blur-lg"
|
|
||||||
>
|
|
||||||
<feature.icon className="w-4 h-4 text-stone-800" />
|
|
||||||
<span className="text-stone-800 font-semibold text-sm">
|
|
||||||
{feature.text}
|
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4">
|
||||||
))}
|
{t("line2")}
|
||||||
</motion.div>
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto xl:mx-0 font-light leading-relaxed tracking-tight">
|
||||||
<motion.div
|
{t("description")}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
</p>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="flex flex-col sm:flex-row gap-5 justify-center items-center"
|
|
||||||
>
|
|
||||||
<motion.a
|
|
||||||
href="#projects"
|
|
||||||
whileHover={{ scale: 1.03, y: -2 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
||||||
className="px-8 py-4 bg-stone-900 text-cream rounded-full shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<span className="text-cream">{t("ctaWork")}</span>
|
|
||||||
<ArrowDown size={18} />
|
|
||||||
</motion.a>
|
|
||||||
|
|
||||||
<motion.a
|
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center xl:justify-start pt-2 sm:pt-4 animate-[fadeIn_0.6s_ease-out_0.3s_both]">
|
||||||
href="#contact"
|
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
||||||
whileHover={{ scale: 1.03, y: -2 }}
|
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||||
whileTap={{ scale: 0.98 }}
|
{t("ctaWork")}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
</a>
|
||||||
className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all duration-500"
|
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-700 dark:text-stone-300 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||||
>
|
{t("ctaContact")}
|
||||||
<span>{t("ctaContact")}</span>
|
</a>
|
||||||
</motion.a>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: The Photo */}
|
||||||
|
<div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 xl:w-[500px] xl:h-[500px] shrink-0 mt-4 sm:mt-8 xl:mt-0 animate-[fadeIn_1s_ease-out]">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
||||||
|
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
||||||
|
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1280px) 320px, 500px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
||||||
|
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4 animate-bounce">
|
||||||
|
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Hero;
|
|
||||||
|
|||||||
@@ -1,34 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion, Variants } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
const fadeInUp: Variants = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const staggerContainer: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.2,
|
|
||||||
delayChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -47,215 +25,106 @@ interface Project {
|
|||||||
|
|
||||||
const Projects = () => {
|
const Projects = () => {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("home.projects");
|
useTranslations("home.projects");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
|
||||||
"/api/projects?featured=true&published=true&limit=6",
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setProjects(data.projects || []);
|
setProjects(data.projects || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.error("Featured projects fetch failed:", error);
|
||||||
console.error("Error loading projects:", error);
|
} finally {
|
||||||
}
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="projects" className="py-16 sm:py-24 md:py-32 px-4 sm:px-6 bg-stone-50 dark:bg-stone-950">
|
||||||
id="projects"
|
|
||||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-peach/15 via-liquid-yellow/10 to-liquid-coral/15 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<motion.div
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
|
||||||
initial="hidden"
|
<div>
|
||||||
whileInView="visible"
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
variants={fadeInUp}
|
</h2>
|
||||||
className="text-center mb-20"
|
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
||||||
>
|
Projects that pushed my boundaries.
|
||||||
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
|
</p>
|
||||||
{t("title")}
|
</div>
|
||||||
</h2>
|
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all text-xs uppercase tracking-widest">
|
||||||
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light">
|
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
||||||
{t("subtitle")}
|
</Link>
|
||||||
</p>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-12">
|
||||||
initial="hidden"
|
{loading ? (
|
||||||
whileInView="visible"
|
Array.from({ length: 2 }).map((_, i) => (
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
<div key={i} className="space-y-6">
|
||||||
variants={staggerContainer}
|
<Skeleton className="aspect-[4/3] rounded-[2.5rem]" />
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
<div className="space-y-3">
|
||||||
>
|
<Skeleton className="h-8 w-1/2" />
|
||||||
{projects.map((project) => (
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
||||||
|
No projects yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projects.map((project) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
variants={fadeInUp}
|
className="group relative"
|
||||||
whileHover={{ y: -8 }}
|
|
||||||
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-[box-shadow,border-color,background-color] duration-500"
|
|
||||||
>
|
>
|
||||||
{/* Project Cover / Image Area */}
|
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
|
{/* Image Card */}
|
||||||
{project.imageUrl ? (
|
<div className="relative aspect-[16/10] sm:aspect-[4/3] rounded-2xl sm:rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-4 sm:mb-6">
|
||||||
<>
|
{project.imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={project.imageUrl}
|
src={project.imageUrl}
|
||||||
alt={project.title}
|
alt={project.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
) : (
|
||||||
</>
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
|
||||||
) : (
|
<span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||||
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
|
)}
|
||||||
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
|
{/* Overlay on Hover */}
|
||||||
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
|
||||||
|
</div>
|
||||||
<div className="relative z-10">
|
|
||||||
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
|
{/* Text Content */}
|
||||||
{project.title.charAt(0)}
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg sm:text-xl md:text-2xl font-bold text-stone-900 dark:text-stone-100 mb-1 sm:mb-2 group-hover:underline decoration-2 underline-offset-4">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm sm:text-base text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex gap-2">
|
||||||
|
{project.tags.slice(0, 2).map(tag => (
|
||||||
|
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
|
||||||
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Texture/Grain Overlay */}
|
|
||||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
|
||||||
|
|
||||||
{/* Animated Shine Effect */}
|
|
||||||
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
|
|
||||||
|
|
||||||
{/* Featured Badge */}
|
|
||||||
{project.featured && (
|
|
||||||
<div className="absolute top-3 left-3 z-20">
|
|
||||||
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
|
|
||||||
{t("featured")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay Links */}
|
|
||||||
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="GitHub"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="Live Demo"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6 flex flex-col flex-1">
|
|
||||||
{/* Stretched Link covering the whole card (including image area) */}
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/projects/${project.slug}`}
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
aria-label={`View project ${project.title}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
|
|
||||||
<Calendar size={12} />
|
|
||||||
<span>{new Date(project.date).getFullYear()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{project.tags.slice(0, 4).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{project.tags.length > 4 && (
|
|
||||||
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
)))}
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="mt-16 text-center"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/projects`}
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white border border-stone-200 rounded-full text-stone-700 font-medium hover:bg-stone-50 hover:border-stone-300 hover:gap-3 transition-all duration-500 ease-out shadow-sm hover:shadow-md"
|
|
||||||
>
|
|
||||||
{t("viewAll")} <ArrowRight size={16} />
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
246
app/components/ReadBooks.tsx
Normal file
246
app/components/ReadBooks.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
|
|
||||||
|
interface BookReview {
|
||||||
|
id: string;
|
||||||
|
hardcover_id?: string;
|
||||||
|
book_title: string;
|
||||||
|
book_author: string;
|
||||||
|
book_image?: string;
|
||||||
|
rating?: number | null;
|
||||||
|
review?: string | null;
|
||||||
|
finished_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StarRating = ({ rating }: { rating: number }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
star <= rating
|
||||||
|
? "text-amber-500 fill-amber-500"
|
||||||
|
: "text-stone-300 dark:text-stone-600"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripHtml = (html: string) => {
|
||||||
|
if (typeof window === 'undefined') return html; // Fallback for SSR
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
return doc.body.textContent || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReadBooks = () => {
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations("home.about.readBooks");
|
||||||
|
const [reviews, setReviews] = useState<BookReview[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const INITIAL_SHOW = 3;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReviews = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
|
||||||
|
{ cache: "default" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.bookReviews) {
|
||||||
|
setReviews(data.bookReviews);
|
||||||
|
} else {
|
||||||
|
setReviews([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error("Error fetching book reviews:", error);
|
||||||
|
}
|
||||||
|
setReviews([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReviews();
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2 w-full">
|
||||||
|
<Skeleton className="h-5 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-1/4 pt-2" />
|
||||||
|
<Skeleton className="h-12 w-full pt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
|
||||||
|
<BookCheck size={16} className="shrink-0" />
|
||||||
|
<span>{t("empty")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
|
||||||
|
const hasMore = reviews.length > INITIAL_SHOW;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
||||||
|
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
||||||
|
{t("title")} ({reviews.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Book Reviews */}
|
||||||
|
{visibleReviews.map((review, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={review.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
delay: index * 0.1,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.02,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
}}
|
||||||
|
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
|
||||||
|
>
|
||||||
|
{/* Background Blob */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.15, 1],
|
||||||
|
opacity: [0.3, 0.45, 0.3],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: index * 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
{/* Book Cover */}
|
||||||
|
{review.book_image && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
||||||
|
<Image
|
||||||
|
src={review.book_image}
|
||||||
|
alt={review.book_title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 80px, 96px"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Book Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
|
||||||
|
{review.book_title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
|
||||||
|
{review.book_author}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Rating (Optional) */}
|
||||||
|
{review.rating && review.rating > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<StarRating rating={review.rating} />
|
||||||
|
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
|
||||||
|
{review.rating}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Text (Optional) */}
|
||||||
|
{review.review && (
|
||||||
|
<p className="text-sm text-stone-700 dark:text-stone-300 leading-relaxed line-clamp-3 italic">
|
||||||
|
“{stripHtml(review.review)}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finished Date */}
|
||||||
|
{review.finished_at && (
|
||||||
|
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
|
||||||
|
{t("finishedAt")}{" "}
|
||||||
|
{new Date(review.finished_at).toLocaleDateString(
|
||||||
|
locale === "de" ? "de-DE" : "en-US",
|
||||||
|
{ year: "numeric", month: "short" }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Show More / Show Less */}
|
||||||
|
{hasMore && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
{t("showLess")} <ChevronUp size={16} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReadBooks;
|
||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
60
app/components/ScrollFadeIn.tsx
Normal file
60
app/components/ScrollFadeIn.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|||||||
38
app/components/ThemeProvider.tsx
Normal file
38
app/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
|
||||||
|
const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
|
||||||
|
theme: "light",
|
||||||
|
setTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("theme") as Theme | null;
|
||||||
|
if (stored === "dark" || stored === "light") {
|
||||||
|
setThemeState(stored);
|
||||||
|
document.documentElement.classList.toggle("dark", stored === "dark");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = (t: Theme) => {
|
||||||
|
setThemeState(t);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("theme", t);
|
||||||
|
} catch {}
|
||||||
|
document.documentElement.classList.toggle("dark", t === "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeCtx);
|
||||||
|
}
|
||||||
32
app/components/ThemeToggle.tsx
Normal file
32
app/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "./ThemeProvider";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="w-9 h-9" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
|
className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-transform"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun size={18} className="text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Moon size={18} className="text-stone-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/components/ui/BentoGrid.tsx
Normal file
60
app/components/ui/BentoGrid.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export const BentoGrid = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BentoGridItem = ({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
header,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
title?: string | React.ReactNode;
|
||||||
|
description?: string | React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="group-hover/bento:translate-x-2 transition duration-200">
|
||||||
|
{icon}
|
||||||
|
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
app/components/ui/Skeleton.tsx
Normal file
16
app/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"animate-pulse rounded-md bg-stone-200/50 dark:bg-stone-800/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Warm Brown & Off-White Palette */
|
/* Warm Brown & Off-White Palette */
|
||||||
--background: #faf8f3; /* Warm off-white */
|
--background: #faf8f3; /* Warm off-white */
|
||||||
@@ -26,8 +27,30 @@
|
|||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #1c1917; /* stone-900 */
|
||||||
|
--foreground: #f5f5f4; /* stone-100 */
|
||||||
|
--card: rgba(28, 25, 23, 0.7);
|
||||||
|
--card-foreground: #f5f5f4;
|
||||||
|
--popover: #1c1917;
|
||||||
|
--popover-foreground: #f5f5f4;
|
||||||
|
--primary: #d6d3d1; /* stone-300 */
|
||||||
|
--primary-foreground: #1c1917;
|
||||||
|
--secondary: #44403c; /* stone-700 */
|
||||||
|
--secondary-foreground: #f5f5f4;
|
||||||
|
--muted: #292524; /* stone-800 */
|
||||||
|
--muted-foreground: #a8a29e; /* stone-400 */
|
||||||
|
--accent: #57534e; /* stone-600 */
|
||||||
|
--accent-foreground: #f5f5f4;
|
||||||
|
--destructive: #7f1d1d; /* dark red */
|
||||||
|
--destructive-foreground: #f5f5f4;
|
||||||
|
--border: #44403c;
|
||||||
|
--input: #292524;
|
||||||
|
--ring: #d6d3d1;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(135deg, rgba(250, 248, 243, 0.95) 0%, rgba(250, 248, 243, 0.92) 100%);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: "Inter", sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -37,6 +60,7 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Selection */
|
/* Custom Selection */
|
||||||
@@ -52,35 +76,33 @@ html {
|
|||||||
|
|
||||||
/* Liquid Glass Effects */
|
/* Liquid Glass Effects */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: rgba(250, 248, 243, 0.75);
|
background: var(--card);
|
||||||
backdrop-filter: blur(20px) saturate(130%);
|
backdrop-filter: blur(20px) saturate(130%);
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(130%);
|
-webkit-backdrop-filter: blur(20px) saturate(130%);
|
||||||
border: 1px solid rgba(215, 204, 200, 0.6);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 8px 32px rgba(62, 39, 35, 0.12);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
will-change: backdrop-filter;
|
will-change: backdrop-filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: rgba(255, 252, 245, 0.85);
|
background: var(--card);
|
||||||
backdrop-filter: blur(30px) saturate(200%);
|
backdrop-filter: blur(30px) saturate(200%);
|
||||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||||
border: 1px solid rgba(215, 204, 200, 0.7);
|
border: 1px solid var(--border);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 6px -1px rgba(62, 39, 35, 0.06),
|
0 4px 6px -1px rgba(0, 0, 0, 0.06),
|
||||||
0 2px 4px -1px rgba(62, 39, 35, 0.05),
|
0 2px 4px -1px rgba(0, 0, 0, 0.05);
|
||||||
inset 0 0 30px rgba(255, 252, 245, 0.6);
|
|
||||||
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
|
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
will-change: transform, box-shadow;
|
will-change: transform, box-shadow;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card:hover {
|
.glass-card:hover {
|
||||||
background: rgba(255, 252, 245, 0.95);
|
background: var(--card);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 25px -5px rgba(62, 39, 35, 0.15),
|
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||||
0 10px 10px -5px rgba(62, 39, 35, 0.08),
|
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||||
inset 0 0 30px rgba(255, 252, 245, 0.9);
|
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
border-color: rgba(215, 204, 200, 0.9);
|
border-color: var(--ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography & Headings */
|
/* Typography & Headings */
|
||||||
@@ -93,7 +115,7 @@ h6 {
|
|||||||
font-family: var(--font-playfair), Georgia, serif;
|
font-family: var(--font-playfair), Georgia, serif;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improve text contrast - using foreground variable for WCAG AA compliance */
|
/* Improve text contrast - using foreground variable for WCAG AA compliance */
|
||||||
@@ -154,34 +176,34 @@ div {
|
|||||||
/* Markdown Specifics for Blog/Projects */
|
/* Markdown Specifics for Blog/Projects */
|
||||||
.markdown h1 {
|
.markdown h1 {
|
||||||
@apply text-4xl font-bold mb-6 tracking-tight;
|
@apply text-4xl font-bold mb-6 tracking-tight;
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown h2 {
|
.markdown h2 {
|
||||||
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
|
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown p {
|
.markdown p {
|
||||||
@apply mb-4 leading-relaxed;
|
@apply mb-4 leading-relaxed;
|
||||||
color: #4e342e;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown a {
|
.markdown a {
|
||||||
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
|
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
|
||||||
color: #5d4037;
|
color: var(--primary);
|
||||||
text-decoration-color: #a1887f;
|
text-decoration-color: var(--accent);
|
||||||
}
|
}
|
||||||
.markdown ul {
|
.markdown ul {
|
||||||
@apply list-disc list-inside mb-4 space-y-2;
|
@apply list-disc list-inside mb-4 space-y-2;
|
||||||
color: #4e342e;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown code {
|
.markdown code {
|
||||||
@apply px-1.5 py-0.5 rounded text-sm font-mono;
|
@apply px-1.5 py-0.5 rounded text-sm font-mono;
|
||||||
background: #efebe9;
|
background: var(--muted);
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown pre {
|
.markdown pre {
|
||||||
@apply p-4 rounded-xl overflow-x-auto mb-6;
|
@apply p-4 rounded-xl overflow-x-auto mb-6;
|
||||||
background: #3e2723;
|
background: var(--foreground);
|
||||||
color: #faf8f3;
|
color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Admin Dashboard Styles - Warm Brown Theme */
|
/* Admin Dashboard Styles - Warm Brown Theme */
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -29,11 +29,14 @@ export default async function RootLayout({
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
|
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
|
{/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||||
|
<div className="grain-overlay" aria-hidden="true" />
|
||||||
<ShaderGradientBackground />
|
<ShaderGradientBackground />
|
||||||
<ClientProviders>{children}</ClientProviders>
|
<ClientProviders>{children}</ClientProviders>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
|
||||||
import { ArrowLeft } from 'lucide-react';
|
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import RichTextClient from "../components/RichTextClient";
|
import RichTextClient from "../components/RichTextClient";
|
||||||
|
|
||||||
export default function LegalNotice() {
|
export default function LegalNotice() {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -24,114 +21,111 @@ export default function LegalNotice() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Only use CMS content if it exists for the active locale.
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
setCmsHtml(data.content.html as string);
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
|
||||||
setCmsTitle((data.content.title as string | null) ?? null);
|
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore; fallback to static content
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen animated-bg">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 pt-32 pb-20">
|
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
{/* Editorial Header */}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="animate-fade-in mb-20">
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>{t("backToHome")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
|
||||||
{cmsTitle || "Impressum"}
|
|
||||||
</h1>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
Legal<span className="text-liquid-mint">.</span>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</h1>
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
</div>
|
||||||
className="glass-card p-8 rounded-2xl space-y-6"
|
|
||||||
>
|
{/* Bento Content Grid */}
|
||||||
{cmsDoc ? (
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
|
|
||||||
) : (
|
{/* Main Legal Content (Large Box) */}
|
||||||
<>
|
<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">
|
||||||
<div className="text-gray-300 leading-relaxed">
|
{cmsHtml ? (
|
||||||
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Inhalte dieser Website</h2>
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<div className="space-y-2 text-gray-300">
|
<RichTextClient html={cmsHtml} />
|
||||||
<p>
|
</div>
|
||||||
<strong>Name:</strong> Dennis Konkol
|
) : (
|
||||||
</p>
|
<div className="space-y-16">
|
||||||
<p>
|
<section>
|
||||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
</p>
|
<Scale className="text-liquid-mint" size={28} /> Angaben gemäß § 5 TMG
|
||||||
<p>
|
</h2>
|
||||||
<strong>E-Mail:</strong>{" "}
|
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-4">
|
||||||
<Link href="mailto:info@dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">
|
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
|
||||||
info@dk0.dev
|
<p>Auf dem Ziegenbrink 2B</p>
|
||||||
</Link>
|
<p>49082 Osnabrück, Deutschland</p>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
</section>
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<Link href="https://www.dk0.dev" className="text-blue-400 hover:text-blue-300 transition-colors">
|
<section>
|
||||||
dk0.dev
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
</Link>
|
<ShieldCheck className="text-liquid-sky" size={28} /> Haftungsausschluss
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.
|
||||||
</p>
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Widgets */}
|
||||||
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|
||||||
|
{/* Quick Contact Box */}
|
||||||
|
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
|
||||||
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Direct Contact</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
|
||||||
|
<Mail className="text-liquid-mint" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Email</p>
|
||||||
|
<Link href="mailto:info@dk0.dev" className="font-bold hover:text-liquid-mint transition-colors">info@dk0.dev</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
|
||||||
|
<MapPin className="text-liquid-sky" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Location</p>
|
||||||
|
<p className="font-bold">Osnabrück, GER</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-300">
|
{/* Meta Info Box */}
|
||||||
<h2 className="text-2xl font-semibold mb-4">Haftung für Links</h2>
|
<div className="bg-liquid-purple/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-purple/20 dark:border-stone-800/60">
|
||||||
<p className="leading-relaxed">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser
|
<Clock className="text-liquid-purple" size={20} />
|
||||||
Websites und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der
|
<div>
|
||||||
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum
|
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p>
|
||||||
Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde
|
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">February 15, 2025</p>
|
||||||
ich derartige Links umgehend entfernen.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-stone-500 leading-relaxed">
|
||||||
|
This legal notice applies to all contents on dk0.dev and related social media profiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-300">
|
</div>
|
||||||
<h2 className="text-2xl font-semibold mb-4">Urheberrecht</h2>
|
</div>
|
||||||
<p className="leading-relaxed">
|
|
||||||
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter
|
|
||||||
Urheberrechtsschutz. Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist
|
|
||||||
verboten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-gray-300">
|
|
||||||
<h2 className="text-2xl font-semibold mb-4">Gewährleistung</h2>
|
|
||||||
<p className="leading-relaxed">
|
|
||||||
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine
|
|
||||||
Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser
|
|
||||||
Website.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t border-gray-700">
|
|
||||||
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,150 +1,109 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Search, Terminal } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Home, ArrowLeft, Search } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
if (!mounted) return null;
|
||||||
if (process.env.NODE_ENV === "test") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
Oops! The page you're looking for doesn't exist.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-[#795548]">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCommand = (cmd: string) => {
|
|
||||||
const command = cmd.toLowerCase().trim();
|
|
||||||
if (command === 'home' || command === 'cd ~' || command === 'cd /') {
|
|
||||||
router.push('/');
|
|
||||||
} else if (command === 'back' || command === 'cd ..') {
|
|
||||||
router.back();
|
|
||||||
} else if (command === 'search') {
|
|
||||||
router.push('/projects');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3] p-4">
|
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-16 sm:py-20 md:py-24 px-4 sm:px-6 flex items-center justify-center transition-colors duration-500">
|
||||||
<div className="w-full max-w-2xl">
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
{/* Terminal-style 404 */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 sm:gap-6 md:gap-8 max-w-5xl mx-auto">
|
||||||
<div className="bg-[#3e2723] rounded-2xl shadow-2xl overflow-hidden border border-[#5d4037]">
|
|
||||||
{/* Terminal Header */}
|
|
||||||
<div className="bg-[#5d4037] px-4 py-3 flex items-center gap-2 border-b border-[#795548]">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-[#d84315]"></div>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-[#bcaaa4]"></div>
|
|
||||||
<div className="w-3 h-3 rounded-full bg-[#a1887f]"></div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 text-[#faf8f3] text-sm font-mono">
|
|
||||||
terminal@portfolio ~ 404
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terminal Body */}
|
{/* Main Error Card */}
|
||||||
<div className="p-6 md:p-8 font-mono text-sm md:text-base">
|
<motion.div
|
||||||
<div className="mb-6">
|
initial={{ opacity: 0, y: 30 }}
|
||||||
<div className="text-[#bcaaa4] mb-2">$ cd {mounted ? window.location.pathname : '/unknown'}</div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="text-[#d84315] mb-4">
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between min-h-[300px] sm:min-h-[400px]"
|
||||||
<span className="mr-2">✗</span>
|
>
|
||||||
Error: ENOENT: no such file or directory
|
<div>
|
||||||
</div>
|
<div className="flex items-center gap-3 mb-6 sm:mb-8 md:mb-12">
|
||||||
<div className="text-[#a1887f] mb-6">
|
<div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
||||||
<pre className="whitespace-pre-wrap">
|
404
|
||||||
{`
|
|
||||||
██╗ ██╗ ██████╗ ██╗ ██╗
|
|
||||||
██║ ██║██╔═████╗██║ ██║
|
|
||||||
███████║██║██╔██║███████║
|
|
||||||
╚════██║████╔╝██║╚════██║
|
|
||||||
██║╚██████╔╝ ██║
|
|
||||||
╚═╝ ╚═════╝ ╚═╝
|
|
||||||
`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[#faf8f3] mb-6">
|
|
||||||
<p className="mb-3">The page you're looking for seems to have wandered off.</p>
|
|
||||||
<p className="text-[#bcaaa4]">Perhaps it never existed, or maybe it's on a coffee break.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 text-[#a1887f]">
|
|
||||||
<div className="mb-2">Available commands:</div>
|
|
||||||
<div className="pl-4 space-y-1 text-sm">
|
|
||||||
<div>→ <span className="text-[#faf8f3]">home</span> - Return to homepage</div>
|
|
||||||
<div>→ <span className="text-[#faf8f3]">back</span> - Go back to previous page</div>
|
|
||||||
<div>→ <span className="text-[#faf8f3]">search</span> - Search the website</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-4xl sm:text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.85] mb-4 sm:mb-6 md:mb-8">
|
||||||
|
Page not <br/>Found<span className="text-liquid-mint">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
|
||||||
|
The content you are looking for has been moved, deleted, or never existed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Interactive Command Line */}
|
<div className="mt-8 sm:mt-10 md:mt-12 flex flex-wrap gap-3 sm:gap-4">
|
||||||
<div className="flex items-center gap-2 border-t border-[#5d4037] pt-4">
|
<Link
|
||||||
<span className="text-[#a1887f]">$</span>
|
href="/"
|
||||||
<input
|
className="group relative px-6 sm:px-10 py-3 sm:py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
|
||||||
type="text"
|
>
|
||||||
value={input}
|
Return Home
|
||||||
onChange={(e) => setInput(e.target.value)}
|
</Link>
|
||||||
onKeyDown={(e) => {
|
<button
|
||||||
if (e.key === 'Enter') {
|
onClick={() => router.back()}
|
||||||
handleCommand(input);
|
className="px-6 sm:px-10 py-3 sm:py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-xl sm:rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
|
||||||
setInput('');
|
>
|
||||||
}
|
Go Back
|
||||||
}}
|
</button>
|
||||||
placeholder="Type a command..."
|
|
||||||
className="flex-1 bg-transparent text-[#faf8f3] outline-none placeholder:text-[#795548] font-mono"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Sidebar Cards */}
|
||||||
|
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6">
|
||||||
|
{/* Search/Explore Projects */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Search className="text-liquid-mint mb-4 sm:mb-6" size={28} />
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2">Explore Work</h3>
|
||||||
|
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/projects"
|
||||||
|
className="mt-5 sm:mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
|
||||||
|
>
|
||||||
|
View Projects <ArrowLeft className="rotate-180" size={14} />
|
||||||
|
</Link>
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Visit the Lab */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between group"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Terminal className="text-liquid-purple mb-4 sm:mb-6" size={28} />
|
||||||
|
<h3 className="text-xl sm:text-2xl font-black uppercase tracking-tighter mb-1 sm:mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/snippets"
|
||||||
|
className="mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Enter the Lab <ArrowLeft className="rotate-180" size={14} />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Action Buttons */}
|
|
||||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
|
||||||
>
|
|
||||||
<Home className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
|
||||||
<span className="text-[#3e2723] font-medium">Home</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
|
||||||
<span className="text-[#3e2723] font-medium">Go Back</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/projects"
|
|
||||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
|
||||||
<span className="text-[#3e2723] font-medium">Explore Projects</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +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 } 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);
|
||||||
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -24,310 +21,116 @@ 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();
|
||||||
// Only use CMS content if it exists for the active locale.
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
setCmsHtml(data.content.html as string);
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
|
||||||
setCmsTitle((data.content.title as string | null) ?? null);
|
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore; fallback to static content
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen animated-bg">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 pt-32 pb-20">
|
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
{/* Editorial Header */}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<div className="animate-fade-in mb-20">
|
||||||
transition={{ duration: 0.8 }}
|
<Link
|
||||||
className="mb-8"
|
|
||||||
>
|
|
||||||
<motion.a
|
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>{t("backToHome")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
|
||||||
</motion.a>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
{cmsTitle || "Datenschutzerklärung"}
|
Privacy<span className="text-liquid-purple">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
className="glass-card p-8 rounded-2xl space-y-6 text-white"
|
|
||||||
>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="text-gray-300 leading-relaxed">
|
|
||||||
<p>
|
|
||||||
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
|
|
||||||
über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-gray-300 leading-relaxed">
|
|
||||||
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Datenverarbeitung</h2>
|
|
||||||
<div className="space-y-2 text-gray-300">
|
|
||||||
<p>
|
|
||||||
<strong>Name:</strong> Dennis Konkol
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>E-Mail:</strong>{" "}
|
|
||||||
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dk0.dev">
|
|
||||||
info@dk0.dev
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dk0.dev">
|
|
||||||
dk0.dev
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4">
|
|
||||||
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
|
|
||||||
Verantwortlichen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
|
||||||
Erfassung allgemeiner Informationen beim Besuch meiner Website
|
|
||||||
</h2>
|
|
||||||
<div className="mt-2">
|
|
||||||
Beim Zugriff auf meiner Website werden automatisch Informationen allgemeiner Natur erfasst. Diese
|
|
||||||
beinhalten unter anderem:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>IP-Adresse (in anonymisierter Form)</li>
|
|
||||||
<li>Uhrzeit</li>
|
|
||||||
<li>Browsertyp</li>
|
|
||||||
<li>Verwendetes Betriebssystem</li>
|
|
||||||
<li>Referrer-URL (die zuvor besuchte Seite)</li>
|
|
||||||
</ul>
|
|
||||||
<br />
|
|
||||||
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
|
|
||||||
Rückschlüsse auf Ihre Person sind nicht möglich. Diese Daten werden verarbeitet, um:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>die Inhalte meiner Website korrekt auszuliefern,</li>
|
|
||||||
<li>die Inhalte meiner Website zu optimieren,</li>
|
|
||||||
<li>die Systemsicherheit und -stabilität zu analysiern.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Cookies</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Diese Website verwendet ein technisch notwendiges Cookie, um deine Datenschutz-Einstellungen (z.B.
|
|
||||||
Analytics/Chatbot) zu speichern. Ohne dieses Cookie wäre ein Consent-Banner bei jedem Besuch erneut
|
|
||||||
nötig.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Analyse- und Tracking-Tools</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Die nachfolgend beschriebene Analyse- und Tracking-Methode (im
|
|
||||||
Folgenden „Maßnahme“ genannt) basiert auf Art. 6 Abs. 1 S. 1 lit. f
|
|
||||||
DSGVO. Durch diese Maßnahme möchten ich eine benutzerfreundliche
|
|
||||||
Gestaltung sowie eine kontinuierliche Verbesserung meiner Website
|
|
||||||
sicherstellen. Diese Interessen sind im Sinne der genannten Vorschrift
|
|
||||||
als berechtigt anzusehen.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes
|
|
||||||
Interesse an der Analyse und Optimierung unserer Website).
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Detaillierte Informationen zu den erhobenen Daten und deren
|
|
||||||
Verarbeitung finden Sie in den nachfolgenden Abschnitten.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Zur Analyse der Nutzung meiner Website setze ich Umami ein. Umami
|
|
||||||
speichert keine IP-Adressen oder Cookies. Alle erfassten Daten sind
|
|
||||||
anonymisiert. Da ich Umami auf meinem eigenen Server betreibe, erfolgt
|
|
||||||
keine Weitergabe an Dritte. Weitere Informationen finden Sie unter{" "}
|
|
||||||
<Link
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
href={"https://umami.is"}
|
|
||||||
>
|
|
||||||
Umami
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4">
|
|
||||||
Zusätzlich kann diese Website optionale, selbst gehostete
|
|
||||||
Nutzungsstatistiken erfassen (z.B. Seitenaufrufe, Performance-Metriken),
|
|
||||||
die erst nach deiner Einwilligung im Consent-Banner aktiviert werden.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Error Monitoring (Sentry)</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Um Fehler und Probleme auf dieser Website schnell zu erkennen und zu beheben,
|
|
||||||
nutze ich Sentry.io, einen Dienst zur Fehlerüberwachung. Dabei werden technische
|
|
||||||
Informationen wie Browser-Typ, Betriebssystem, URL der aufgerufenen Seite und
|
|
||||||
Fehlermeldungen an Sentry übermittelt. Diese Daten dienen ausschließlich der
|
|
||||||
Verbesserung der Website-Stabilität und werden nicht für andere Zwecke verwendet.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Anbieter: Functional Software, Inc. (Sentry), 45 Fremont Street, 8th Floor,
|
|
||||||
San Francisco, CA 94105, USA
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes Interesse an
|
|
||||||
der Fehleranalyse und Systemstabilität).
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Weitere Informationen: <Link
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
href={"https://sentry.io/privacy/"}
|
|
||||||
>
|
|
||||||
Sentry Datenschutzerklärung
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Kontaktformular</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Wenn Sie das Kontaktformular nutzen, werden Ihre Angaben zur
|
|
||||||
Bearbeitung Ihrer Anfrage gespeichert. Diese Daten werden nicht an
|
|
||||||
Dritte weitergegeben und nach Erfüllung des Zwecks gelöscht. <br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Chatbot</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Wenn du den optionalen Chatbot nutzt, werden die von dir eingegebenen
|
|
||||||
Nachrichten verarbeitet, um eine Antwort zu generieren. Die Verarbeitung
|
|
||||||
kann dabei über eine selbst gehostete Automations-/Chat-Infrastruktur
|
|
||||||
(z.B. n8n) erfolgen. Bitte gib im Chat keine sensiblen Daten ein.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung) – der
|
|
||||||
Chatbot wird erst nach Aktivierung im Consent-Banner geladen.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Social Media Links</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Unsere Website enthält Links zu GitHub und LinkedIn. Durch das
|
|
||||||
Anklicken dieser Links gelten die Datenschutzbestimmungen der
|
|
||||||
jeweiligen Anbieter.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Weitergabe von Daten</h2>
|
|
||||||
<div className="mt-2">
|
|
||||||
Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>
|
|
||||||
Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt
|
|
||||||
haben,
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO
|
|
||||||
erforderlich ist,
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
eine gesetzliche Verpflichtung zur Weitergabe nach Art. 6 Abs. 1
|
|
||||||
S. 1 lit. c DSGVO besteht oder
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung
|
|
||||||
berechtigter Interessen erforderlich ist.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
|
||||||
Speicherdauer und Löschung
|
{/* Bento Content Grid */}
|
||||||
</h2>
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
<p className="mt-2">
|
|
||||||
Ihre Daten werden nur solange gespeichert, wie dies für die Erfüllung
|
{/* Main Privacy Text (Large) */}
|
||||||
des Verarbeitungszwecks erforderlich ist. Nach Erfüllung des Zwecks
|
<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">
|
||||||
werden Ihre Daten gelöscht.
|
{cmsHtml ? (
|
||||||
</p>
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<h2 className="text-2xl font-semibold mt-6">Ihre Rechte</h2>
|
<RichTextClient html={cmsHtml} />
|
||||||
<div className="mt-2">
|
</div>
|
||||||
Sie haben gemäß DSGVO folgende Rechte:
|
) : (
|
||||||
<ul className="list-disc list-inside mt-2">
|
<div className="space-y-16">
|
||||||
<li>
|
<section>
|
||||||
Art. 15 DSGVO: Auskunftsrecht über Ihre von mir gespeicherten
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
Daten
|
<Shield className="text-liquid-mint" size={28} /> Allgemeiner Überblick
|
||||||
</li>
|
</h2>
|
||||||
<li>
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
Art. 16 DSGVO: Recht auf Berichtigung unrichtiger oder
|
Der Schutz Ihrer persönlichen Daten ist mir ein besonderes Anliegen. Ich verarbeite Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, TMG).
|
||||||
unvollständiger Daten
|
</p>
|
||||||
</li>
|
</section>
|
||||||
<li>
|
|
||||||
Art. 17 DSGVO: Recht auf Löschung Ihrer bei mir gespeicherten
|
<section>
|
||||||
Daten (soweit keine gesetzlichen Aufbewahrungspflichten
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
entgegenstehen)
|
<Database className="text-liquid-sky" size={28} /> Datenerfassung
|
||||||
</li>
|
</h2>
|
||||||
<li>
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung Ihrer
|
Wenn Sie per Formular auf der Website oder per E-Mail Kontakt mit mir aufnehmen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage bei mir gespeichert.
|
||||||
Daten
|
</p>
|
||||||
</li>
|
</section>
|
||||||
<li>Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
|
</div>
|
||||||
<li>
|
)}
|
||||||
Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung Ihrer
|
|
||||||
Daten
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<br />
|
|
||||||
Falls Sie eine Einwilligung erklärt haben, können Sie diese jederzeit
|
|
||||||
widerrufen.
|
|
||||||
<br />
|
|
||||||
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde
|
|
||||||
richten. Eine Liste der Datenschutzbeauftragten sowie deren
|
|
||||||
Kontaktdaten finden Sie unter:{" "}
|
|
||||||
<Link
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
href={"https://www.bfdi.bund.de/"}
|
|
||||||
>
|
|
||||||
https://www.bfdi.bund.de/
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Datensicherheit</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten
|
|
||||||
zu schützen. Dazu gehören unter anderem die SSL-Verschlüsselung. Diese
|
|
||||||
Verschlüsselung erkennen Sie an dem Schloss-Symbol in der Adresszeile
|
|
||||||
Ihres Browsers und an der URL, die mit "https://" beginnt.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Kontakt</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Bei Fragen zur Datenschutzerklärung kontaktieren Sie mich unter{" "}
|
|
||||||
<Link
|
|
||||||
href="mailto:info@dk0.dev"
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
>
|
|
||||||
info@dk0.dev
|
|
||||||
</Link>{" "}
|
|
||||||
oder nutzen Sie das Kontaktformular auf meiner Website.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
|
||||||
Änderungen der Datenschutzerklärung
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den
|
|
||||||
gesetzlichen Anforderungen zu entsprechen und neue Entwicklungen zu
|
|
||||||
berücksichtigen. Die jeweils aktuelle Datenschutzerklärung finden Sie
|
|
||||||
auf meiner Website.
|
|
||||||
</p>
|
|
||||||
<div className="pt-4 border-t border-gray-700">
|
|
||||||
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
{/* Quick Info Cards */}
|
||||||
</motion.div>
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|
||||||
|
{/* Core Values Box */}
|
||||||
|
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
|
||||||
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-purple">Principles</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Lock className="text-liquid-mint mt-1" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Encryption</p>
|
||||||
|
<p className="text-xs text-stone-500">SSL/TLS secured data transfer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Eye className="text-liquid-sky mt-1" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Transparency</p>
|
||||||
|
<p className="text-xs text-stone-500">No hidden tracking algorithms.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Globe className="text-liquid-purple mt-1" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Compliance</p>
|
||||||
|
<p className="text-xs text-stone-500">GDPR / DSGVO optimized.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cookie Status Indicator */}
|
||||||
|
<div className="bg-liquid-mint/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-mint/20 dark:border-stone-800/60">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
|
<p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('cookie-consent');
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest border-b border-stone-300 dark:border-stone-700 pb-1 hover:text-liquid-mint transition-colors"
|
||||||
|
>
|
||||||
|
Reset Privacy Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user