Compare commits
49 Commits
e0e0551a83
...
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 |
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.
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
name: Gitea CI
|
name: CI / CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, production]
|
branches: [main, dev, production]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, production]
|
branches: [main, dev, production]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '25'
|
||||||
|
DOCKER_IMAGE: portfolio-app
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ── Job 1: Lint, Test, Build (runs on every push/PR) ──
|
||||||
test-build:
|
test-build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -16,10 +21,10 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install deps
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
@@ -28,5 +33,247 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
- name: Build
|
- name: Type check
|
||||||
run: npm run build
|
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,300 +0,0 @@
|
|||||||
name: Dev Deployment (Zero Downtime)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ dev ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '25'
|
|
||||||
DOCKER_IMAGE: portfolio-app
|
|
||||||
IMAGE_TAG: dev
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-dev:
|
|
||||||
runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linting
|
|
||||||
run: npm run lint
|
|
||||||
continue-on-error: true # Don't block dev deployments on lint errors
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: npm run test
|
|
||||||
continue-on-error: true # Don't block dev deployments on test failures
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
echo "🏗️ Building dev Docker image with BuildKit cache..."
|
|
||||||
DOCKER_BUILDKIT=1 docker build \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
|
||||||
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
.
|
|
||||||
echo "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
- name: Zero-Downtime Dev Deployment
|
|
||||||
run: |
|
|
||||||
echo "🚀 Starting zero-downtime dev deployment..."
|
|
||||||
|
|
||||||
CONTAINER_NAME="portfolio-app-dev"
|
|
||||||
HEALTH_PORT="3001"
|
|
||||||
IMAGE_NAME="${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }}"
|
|
||||||
|
|
||||||
# Check for existing container (running or stopped)
|
|
||||||
EXISTING_CONTAINER=$(docker ps -aq -f name=$CONTAINER_NAME || echo "")
|
|
||||||
|
|
||||||
# Start DB and Redis if not running
|
|
||||||
echo "🗄️ Starting database and Redis..."
|
|
||||||
COMPOSE_FILE="docker-compose.dev.minimal.yml"
|
|
||||||
|
|
||||||
# Stop and remove existing containers to ensure clean start with correct architecture
|
|
||||||
echo "🧹 Cleaning up existing containers..."
|
|
||||||
docker stop portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true
|
|
||||||
docker rm portfolio_postgres_dev portfolio_redis_dev 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove old images to force re-pull with correct architecture
|
|
||||||
echo "🔄 Removing old images to force re-pull..."
|
|
||||||
docker rmi postgres:15-alpine redis:7-alpine 2>/dev/null || true
|
|
||||||
|
|
||||||
# Ensure networks exist before compose starts (network is external)
|
|
||||||
echo "🌐 Ensuring networks exist..."
|
|
||||||
docker network create portfolio_dev 2>/dev/null || true
|
|
||||||
docker network create proxy 2>/dev/null || true
|
|
||||||
|
|
||||||
# Pull images with correct architecture (Docker will auto-detect)
|
|
||||||
echo "📥 Pulling images for current architecture..."
|
|
||||||
docker compose -f $COMPOSE_FILE pull postgres redis
|
|
||||||
|
|
||||||
# Start containers
|
|
||||||
echo "📦 Starting PostgreSQL and Redis containers..."
|
|
||||||
docker compose -f $COMPOSE_FILE up -d postgres redis
|
|
||||||
|
|
||||||
# Wait for DB to be ready
|
|
||||||
echo "⏳ Waiting for database to be ready..."
|
|
||||||
for i in {1..30}; do
|
|
||||||
if docker exec portfolio_postgres_dev pg_isready -U portfolio_user -d portfolio_dev >/dev/null 2>&1; then
|
|
||||||
echo "✅ Database is ready!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting for database... ($i/30)"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Export environment variables
|
|
||||||
export NODE_ENV=production
|
|
||||||
export LOG_LEVEL=${LOG_LEVEL:-debug}
|
|
||||||
export NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev}
|
|
||||||
export DATABASE_URL="postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public"
|
|
||||||
export REDIS_URL="redis://portfolio_redis_dev:6379"
|
|
||||||
export MY_EMAIL=${MY_EMAIL}
|
|
||||||
export MY_INFO_EMAIL=${MY_INFO_EMAIL}
|
|
||||||
export MY_PASSWORD=${MY_PASSWORD}
|
|
||||||
export MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
|
||||||
export ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH}
|
|
||||||
export ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
|
||||||
export N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''}
|
|
||||||
export N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''}
|
|
||||||
export PORT=${HEALTH_PORT}
|
|
||||||
|
|
||||||
# Stop and remove existing container if it exists (running or stopped)
|
|
||||||
if [ ! -z "$EXISTING_CONTAINER" ]; then
|
|
||||||
echo "🛑 Stopping and removing existing container..."
|
|
||||||
docker stop $EXISTING_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $EXISTING_CONTAINER 2>/dev/null || true
|
|
||||||
echo "✅ Old container removed"
|
|
||||||
# Wait for Docker to release the port
|
|
||||||
echo "⏳ Waiting for Docker to release port ${HEALTH_PORT}..."
|
|
||||||
sleep 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if port is still in use by Docker containers (check all containers, not just running)
|
|
||||||
PORT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "")
|
|
||||||
if [ ! -z "$PORT_CONTAINER" ]; then
|
|
||||||
echo "⚠️ Port ${HEALTH_PORT} is still in use by container $PORT_CONTAINER"
|
|
||||||
echo "🛑 Stopping and removing container using port..."
|
|
||||||
docker stop $PORT_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $PORT_CONTAINER 2>/dev/null || true
|
|
||||||
sleep 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also check for any containers with the same name that might be using the port
|
|
||||||
SAME_NAME_CONTAINER=$(docker ps -a -q -f name=$CONTAINER_NAME | head -1 || echo "")
|
|
||||||
if [ ! -z "$SAME_NAME_CONTAINER" ] && [ "$SAME_NAME_CONTAINER" != "$EXISTING_CONTAINER" ]; then
|
|
||||||
echo "⚠️ Found another container with same name: $SAME_NAME_CONTAINER"
|
|
||||||
docker stop $SAME_NAME_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $SAME_NAME_CONTAINER 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Also check if port is in use by another process (non-Docker)
|
|
||||||
PORT_IN_USE=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || ss -tlnp | grep ":${HEALTH_PORT} " | head -1 || echo "")
|
|
||||||
if [ ! -z "$PORT_IN_USE" ] && [ -z "$PORT_CONTAINER" ]; then
|
|
||||||
echo "⚠️ Port ${HEALTH_PORT} is in use by process"
|
|
||||||
echo "Attempting to free the port..."
|
|
||||||
# Try to find and kill the process
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
|
||||||
PID=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "")
|
|
||||||
if [ ! -z "$PID" ]; then
|
|
||||||
kill -9 $PID 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Final check: verify port is free and wait if needed
|
|
||||||
echo "🔍 Verifying port ${HEALTH_PORT} is free..."
|
|
||||||
MAX_WAIT=10
|
|
||||||
WAIT_COUNT=0
|
|
||||||
while [ $WAIT_COUNT -lt $MAX_WAIT ]; do
|
|
||||||
PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "")
|
|
||||||
if [ -z "$PORT_CHECK" ]; then
|
|
||||||
# Also check with lsof/ss if available
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
|
||||||
PORT_CHECK=$(lsof -ti:${HEALTH_PORT} 2>/dev/null || echo "")
|
|
||||||
elif command -v ss >/dev/null 2>&1; then
|
|
||||||
PORT_CHECK=$(ss -tlnp | grep ":${HEALTH_PORT} " || echo "")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ -z "$PORT_CHECK" ]; then
|
|
||||||
echo "✅ Port ${HEALTH_PORT} is free!"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
WAIT_COUNT=$((WAIT_COUNT + 1))
|
|
||||||
echo "⏳ Port still in use, waiting... ($WAIT_COUNT/$MAX_WAIT)"
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# If port is still in use, try alternative port
|
|
||||||
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
|
|
||||||
echo "⚠️ Port ${HEALTH_PORT} is still in use after waiting. Trying alternative port..."
|
|
||||||
HEALTH_PORT="3002"
|
|
||||||
echo "🔄 Using alternative port: ${HEALTH_PORT}"
|
|
||||||
# Quick check if alternative port is also in use
|
|
||||||
ALT_PORT_CHECK=$(docker ps --format "{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" || echo "")
|
|
||||||
if [ ! -z "$ALT_PORT_CHECK" ]; then
|
|
||||||
echo "❌ Alternative port ${HEALTH_PORT} is also in use!"
|
|
||||||
echo "Attempting to free alternative port..."
|
|
||||||
ALT_CONTAINER=$(docker ps -a --format "{{.ID}}\t{{.Names}}\t{{.Ports}}" | grep -E "(:${HEALTH_PORT}->|:${HEALTH_PORT}/)" | awk '{print $1}' | head -1 || echo "")
|
|
||||||
if [ ! -z "$ALT_CONTAINER" ]; then
|
|
||||||
docker stop $ALT_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $ALT_CONTAINER 2>/dev/null || true
|
|
||||||
sleep 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Start new container with updated image
|
|
||||||
echo "🆕 Starting new dev container..."
|
|
||||||
docker run -d \
|
|
||||||
--name $CONTAINER_NAME \
|
|
||||||
--restart unless-stopped \
|
|
||||||
--network portfolio_dev \
|
|
||||||
-p ${HEALTH_PORT}:3000 \
|
|
||||||
-e NODE_ENV=production \
|
|
||||||
-e LOG_LEVEL=${LOG_LEVEL:-debug} \
|
|
||||||
-e NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL_DEV:-https://dev.dk0.dev} \
|
|
||||||
-e DATABASE_URL=${DATABASE_URL} \
|
|
||||||
-e REDIS_URL=${REDIS_URL} \
|
|
||||||
-e MY_EMAIL=${MY_EMAIL} \
|
|
||||||
-e MY_INFO_EMAIL=${MY_INFO_EMAIL} \
|
|
||||||
-e MY_PASSWORD=${MY_PASSWORD} \
|
|
||||||
-e MY_INFO_PASSWORD=${MY_INFO_PASSWORD} \
|
|
||||||
-e ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH} \
|
|
||||||
-e ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} \
|
|
||||||
-e N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-''} \
|
|
||||||
-e N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-''} \
|
|
||||||
$IMAGE_NAME
|
|
||||||
|
|
||||||
# Connect container to proxy network as well (for external access)
|
|
||||||
echo "🔗 Connecting container to proxy network..."
|
|
||||||
docker network connect proxy $CONTAINER_NAME 2>/dev/null || echo "Container might already be connected to proxy network"
|
|
||||||
|
|
||||||
# Wait for new container to be healthy
|
|
||||||
echo "⏳ Waiting for new container to be healthy..."
|
|
||||||
HEALTH_CHECK_PASSED=false
|
|
||||||
for i in {1..60}; do
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
# Check Docker health status
|
|
||||||
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
if [ "$HEALTH" == "healthy" ]; then
|
|
||||||
echo "✅ New container is healthy!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# Also check HTTP health endpoint
|
|
||||||
if curl -f http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ New container is responding!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting... ($i/60)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Verify new container is working
|
|
||||||
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
|
|
||||||
echo "⚠️ New dev container health check failed, but continuing (non-blocking)..."
|
|
||||||
docker logs $CONTAINER_NAME --tail=50
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove old container if it exists and is different
|
|
||||||
if [ ! -z "$OLD_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f name=$CONTAINER_NAME)
|
|
||||||
if [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
|
||||||
echo "🧹 Removing old container..."
|
|
||||||
docker stop $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Dev deployment completed!"
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'debug' }}
|
|
||||||
NEXT_PUBLIC_BASE_URL_DEV: ${{ vars.NEXT_PUBLIC_BASE_URL_DEV || 'https://dev.dk0.dev' }}
|
|
||||||
DATABASE_URL: postgresql://portfolio_user:portfolio_dev_pass@portfolio_postgres_dev:5432/portfolio_dev?schema=public
|
|
||||||
REDIS_URL: redis://portfolio_redis_dev:6379
|
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
|
||||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
|
||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
|
||||||
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
|
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
|
||||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
|
||||||
|
|
||||||
- name: Dev Health Check
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running dev health checks..."
|
|
||||||
for i in {1..20}; do
|
|
||||||
if curl -f http://localhost:3001/api/health && curl -f http://localhost:3001/ > /dev/null; then
|
|
||||||
echo "✅ Dev is fully operational!"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting for dev... ($i/20)"
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
echo "⚠️ Dev health check failed, but continuing (non-blocking)..."
|
|
||||||
docker logs portfolio-app-dev --tail=50
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
run: |
|
|
||||||
echo "🧹 Cleaning up old images..."
|
|
||||||
docker image prune -f
|
|
||||||
echo "✅ Cleanup completed"
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
name: Production Deployment (Zero Downtime)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ production ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: '25'
|
|
||||||
DOCKER_IMAGE: portfolio-app
|
|
||||||
IMAGE_TAG: production
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-production:
|
|
||||||
runs-on: ubuntu-latest # Gitea Actions: Use runner with ubuntu-latest label
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run linting and tests in parallel
|
|
||||||
run: |
|
|
||||||
npm run lint &
|
|
||||||
LINT_PID=$!
|
|
||||||
npm run test:production &
|
|
||||||
TEST_PID=$!
|
|
||||||
wait $LINT_PID $TEST_PID
|
|
||||||
|
|
||||||
- name: Build application
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: |
|
|
||||||
echo "🏗️ Building production Docker image with BuildKit cache..."
|
|
||||||
DOCKER_BUILDKIT=1 docker build \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
--cache-from ${{ env.DOCKER_IMAGE }}:latest \
|
|
||||||
-t ${{ env.DOCKER_IMAGE }}:${{ env.IMAGE_TAG }} \
|
|
||||||
-t ${{ env.DOCKER_IMAGE }}:latest \
|
|
||||||
.
|
|
||||||
echo "✅ Docker image built successfully"
|
|
||||||
|
|
||||||
- name: Zero-Downtime Production Deployment
|
|
||||||
run: |
|
|
||||||
echo "🚀 Starting zero-downtime production deployment..."
|
|
||||||
|
|
||||||
COMPOSE_FILE="docker-compose.production.yml"
|
|
||||||
CONTAINER_NAME="portfolio-app"
|
|
||||||
HEALTH_PORT="3000"
|
|
||||||
|
|
||||||
# Backup current container ID if running (exact name match to avoid staging)
|
|
||||||
OLD_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$" || echo "")
|
|
||||||
|
|
||||||
# Export environment variables for docker-compose
|
|
||||||
export N8N_WEBHOOK_URL="${{ vars.N8N_WEBHOOK_URL || '' }}"
|
|
||||||
export N8N_SECRET_TOKEN="${{ secrets.N8N_SECRET_TOKEN || '' }}"
|
|
||||||
export N8N_API_KEY="${{ vars.N8N_API_KEY || '' }}"
|
|
||||||
|
|
||||||
# Also export other variables that docker-compose needs
|
|
||||||
export MY_EMAIL="${{ vars.MY_EMAIL }}"
|
|
||||||
export MY_INFO_EMAIL="${{ vars.MY_INFO_EMAIL }}"
|
|
||||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
|
||||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
|
||||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
|
||||||
export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}"
|
|
||||||
export DIRECTUS_URL="${{ vars.DIRECTUS_URL || 'https://cms.dk0.dev' }}"
|
|
||||||
export DIRECTUS_STATIC_TOKEN="${{ secrets.DIRECTUS_STATIC_TOKEN || '' }}"
|
|
||||||
|
|
||||||
# Ensure the shared network exists before compose tries to use it
|
|
||||||
docker network create portfolio_net 2>/dev/null || true
|
|
||||||
|
|
||||||
# Start new container with updated image (docker-compose will handle this)
|
|
||||||
echo "🆕 Starting new production container..."
|
|
||||||
echo "📝 Environment check: N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-(not set)}"
|
|
||||||
docker compose -f $COMPOSE_FILE up -d portfolio
|
|
||||||
|
|
||||||
# Wait for new container to be healthy
|
|
||||||
echo "⏳ Waiting for new container to be healthy..."
|
|
||||||
HEALTH_CHECK_PASSED=false
|
|
||||||
for i in {1..90}; do
|
|
||||||
# Get the production container ID (exact name match, exclude staging)
|
|
||||||
# Use compose project to ensure we get the right container
|
|
||||||
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
# Fallback: try exact name match with leading slash
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
# Verify it's actually the production container by checking compose project label
|
|
||||||
CONTAINER_PROJECT=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.project"}}' 2>/dev/null || echo "")
|
|
||||||
CONTAINER_SERVICE=$(docker inspect $NEW_CONTAINER --format='{{index .Config.Labels "com.docker.compose.service"}}' 2>/dev/null || echo "")
|
|
||||||
if [ "$CONTAINER_SERVICE" == "portfolio" ] || [ -z "$CONTAINER_PROJECT" ] || echo "$CONTAINER_PROJECT" | grep -q "portfolio"; then
|
|
||||||
# Check Docker health status first (most reliable)
|
|
||||||
HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
if [ "$HEALTH" == "healthy" ]; then
|
|
||||||
echo "✅ New container is healthy (Docker health check)!"
|
|
||||||
# Also verify HTTP endpoint from inside container
|
|
||||||
if docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ Container HTTP endpoint is also responding!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "⚠️ Docker health check passed, but HTTP endpoint test failed. Continuing..."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
# Try HTTP health endpoint from host (may not work if port not mapped yet)
|
|
||||||
if curl -f -s --max-time 2 http://localhost:$HEALTH_PORT/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ New container is responding to HTTP health check from host!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
# Show container status for debugging
|
|
||||||
if [ $((i % 10)) -eq 0 ]; then
|
|
||||||
echo "📊 Container ID: $NEW_CONTAINER"
|
|
||||||
echo "📊 Container name: $(docker inspect $NEW_CONTAINER --format='{{.Name}}' 2>/dev/null || echo 'unknown')"
|
|
||||||
echo "📊 Container status: $(docker inspect $NEW_CONTAINER --format='{{.State.Status}}' 2>/dev/null || echo 'unknown')"
|
|
||||||
echo "📊 Health status: $HEALTH"
|
|
||||||
echo "📊 Testing from inside container:"
|
|
||||||
docker exec $NEW_CONTAINER curl -f -s --max-time 2 http://localhost:3000/api/health 2>&1 | head -1 || echo "Container HTTP test failed"
|
|
||||||
docker compose -f $COMPOSE_FILE logs --tail=5 portfolio 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ Found container but it's not from production compose file (skipping): $NEW_CONTAINER"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo "⏳ Waiting... ($i/90)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Final verification: Check Docker health status (most reliable)
|
|
||||||
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
FINAL_HEALTH=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
if [ "$FINAL_HEALTH" == "healthy" ]; then
|
|
||||||
echo "✅ Final verification: Container is healthy!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify new container is working
|
|
||||||
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
|
|
||||||
echo "❌ New container failed health check!"
|
|
||||||
echo "📋 All running containers with 'portfolio' in name:"
|
|
||||||
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Image}}"
|
|
||||||
echo "📋 Production container from compose:"
|
|
||||||
docker compose -f $COMPOSE_FILE ps portfolio 2>/dev/null || echo "No container found via compose"
|
|
||||||
echo "📋 Container logs:"
|
|
||||||
docker compose -f $COMPOSE_FILE logs --tail=100 portfolio 2>/dev/null || echo "Could not get logs"
|
|
||||||
|
|
||||||
# Get the correct container ID
|
|
||||||
NEW_CONTAINER=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ]; then
|
|
||||||
echo "📋 Container inspect (ID: $NEW_CONTAINER):"
|
|
||||||
docker inspect $NEW_CONTAINER --format='{{.Name}} - {{.State.Status}} - Health: {{.State.Health.Status}}' 2>/dev/null || echo "Container not found"
|
|
||||||
echo "📋 Testing health endpoint from inside container:"
|
|
||||||
docker exec $NEW_CONTAINER curl -f -s --max-time 5 http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
|
|
||||||
|
|
||||||
# Check Docker health status - if it's healthy, accept it
|
|
||||||
FINAL_HEALTH_CHECK=$(docker inspect $NEW_CONTAINER --format='{{.State.Health.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
if [ "$FINAL_HEALTH_CHECK" == "healthy" ]; then
|
|
||||||
echo "✅ Docker health check reports healthy - accepting deployment!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
else
|
|
||||||
echo "❌ Docker health check also reports: $FINAL_HEALTH_CHECK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "⚠️ Could not find production container!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove old container if it exists and is different
|
|
||||||
if [ ! -z "$OLD_CONTAINER" ]; then
|
|
||||||
# Get the new production container ID
|
|
||||||
NEW_CONTAINER=$(docker ps --filter "name=$CONTAINER_NAME" --filter "name=^${CONTAINER_NAME}$" --format "{{.ID}}" | head -1)
|
|
||||||
if [ -z "$NEW_CONTAINER" ]; then
|
|
||||||
NEW_CONTAINER=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
if [ ! -z "$NEW_CONTAINER" ] && [ "$OLD_CONTAINER" != "$NEW_CONTAINER" ]; then
|
|
||||||
echo "🧹 Removing old container..."
|
|
||||||
docker stop $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
docker rm $OLD_CONTAINER 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Production deployment completed with zero downtime!"
|
|
||||||
env:
|
|
||||||
NODE_ENV: production
|
|
||||||
LOG_LEVEL: ${{ vars.LOG_LEVEL || 'info' }}
|
|
||||||
NEXT_PUBLIC_BASE_URL: ${{ vars.NEXT_PUBLIC_BASE_URL_PRODUCTION || 'https://dk0.dev' }}
|
|
||||||
MY_EMAIL: ${{ vars.MY_EMAIL }}
|
|
||||||
MY_INFO_EMAIL: ${{ vars.MY_INFO_EMAIL }}
|
|
||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
|
||||||
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
|
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
|
||||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
|
||||||
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
|
|
||||||
|
|
||||||
- name: Production Health Check
|
|
||||||
run: |
|
|
||||||
echo "🔍 Running production health checks..."
|
|
||||||
COMPOSE_FILE="docker-compose.production.yml"
|
|
||||||
CONTAINER_NAME="portfolio-app"
|
|
||||||
|
|
||||||
# Get the production container ID
|
|
||||||
CONTAINER_ID=$(docker compose -f $COMPOSE_FILE ps -q portfolio 2>/dev/null | head -1)
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
|
||||||
CONTAINER_ID=$(docker ps -q -f "name=^/${CONTAINER_NAME}$")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$CONTAINER_ID" ]; then
|
|
||||||
echo "❌ Production container not found!"
|
|
||||||
docker ps --filter "name=portfolio" --format "table {{.ID}}\t{{.Names}}\t{{.Status}}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "📦 Found container: $CONTAINER_ID"
|
|
||||||
|
|
||||||
# Wait for container to be healthy (using Docker's health check)
|
|
||||||
HEALTH_CHECK_PASSED=false
|
|
||||||
for i in {1..30}; do
|
|
||||||
HEALTH=$(docker inspect $CONTAINER_ID --format='{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
||||||
STATUS=$(docker inspect $CONTAINER_ID --format='{{.State.Status}}' 2>/dev/null || echo "unknown")
|
|
||||||
|
|
||||||
if [ "$HEALTH" == "healthy" ] && [ "$STATUS" == "running" ]; then
|
|
||||||
echo "✅ Container is healthy and running!"
|
|
||||||
|
|
||||||
# Test from inside the container (most reliable)
|
|
||||||
if docker exec $CONTAINER_ID curl -f -s --max-time 5 http://localhost:3000/api/health > /dev/null 2>&1; then
|
|
||||||
echo "✅ Health endpoint responds from inside container!"
|
|
||||||
HEALTH_CHECK_PASSED=true
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "⚠️ Container is healthy but HTTP endpoint test failed. Retrying..."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ $((i % 5)) -eq 0 ]; then
|
|
||||||
echo "📊 Status: $STATUS, Health: $HEALTH (attempt $i/30)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "⏳ Waiting for production... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$HEALTH_CHECK_PASSED" != "true" ]; then
|
|
||||||
echo "❌ Production health check failed!"
|
|
||||||
echo "📋 Container status:"
|
|
||||||
docker inspect $CONTAINER_ID --format='Name: {{.Name}}, Status: {{.State.Status}}, Health: {{.State.Health.Status}}' 2>/dev/null || echo "Could not inspect container"
|
|
||||||
echo "📋 Container logs:"
|
|
||||||
docker compose -f $COMPOSE_FILE logs --tail=50 portfolio 2>/dev/null || docker logs $CONTAINER_ID --tail=50 2>/dev/null || echo "Could not get logs"
|
|
||||||
echo "📋 Testing from inside container:"
|
|
||||||
docker exec $CONTAINER_ID curl -v http://localhost:3000/api/health 2>&1 || echo "Container HTTP test failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Production is fully operational!"
|
|
||||||
|
|
||||||
- name: Cleanup
|
|
||||||
run: |
|
|
||||||
echo "🧹 Cleaning up old images..."
|
|
||||||
docker image prune -f
|
|
||||||
echo "✅ Cleanup completed"
|
|
||||||
240
.github/copilot-instructions.md
vendored
240
.github/copilot-instructions.md
vendored
@@ -1,211 +1,107 @@
|
|||||||
# Portfolio Project Instructions
|
# Portfolio Project Instructions
|
||||||
|
|
||||||
This is Dennis Konkol's personal portfolio (dk0.dev) - a Next.js 15 portfolio with Directus CMS integration, n8n automation, and a "liquid" design system.
|
Dennis Konkol's portfolio (dk0.dev) — Next.js 15, Directus CMS, n8n automation, "Liquid Editorial Bento" design system.
|
||||||
|
|
||||||
## Build, Test, and Lint
|
## Build, Test, and Lint
|
||||||
|
|
||||||
### Development
|
|
||||||
```bash
|
|
||||||
npm run dev # Full dev environment (Docker + Next.js)
|
|
||||||
npm run dev:simple # Next.js only (no Docker dependencies)
|
|
||||||
npm run dev:next # Plain Next.js dev server
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build & Deploy
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run dev:next # Plain Next.js dev server (no Docker)
|
||||||
npm run build # Production build (standalone mode)
|
npm run build # Production build (standalone mode)
|
||||||
npm run start # Start production server
|
npm run lint # ESLint (0 errors required, warnings OK)
|
||||||
```
|
npm run lint:fix # Auto-fix lint issues
|
||||||
|
npm run test # All Jest unit tests
|
||||||
### Testing
|
npx jest path/to/test.tsx # Run a single test file
|
||||||
```bash
|
|
||||||
# Unit tests (Jest)
|
|
||||||
npm run test # Run all unit tests
|
|
||||||
npm run test:watch # Watch mode
|
npm run test:watch # Watch mode
|
||||||
npm run test:coverage # With coverage report
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
npm run db:generate # Regenerate Prisma client after schema changes
|
||||||
# E2E tests (Playwright)
|
|
||||||
npm run test:e2e # Run all E2E tests
|
|
||||||
npm run test:e2e:ui # Interactive UI mode
|
|
||||||
npm run test:critical # Critical paths only
|
|
||||||
npm run test:hydration # Hydration tests only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Linting
|
## Architecture
|
||||||
```bash
|
|
||||||
npm run lint # Run ESLint
|
|
||||||
npm run lint:fix # Auto-fix issues
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database (Prisma)
|
### Server/Client Component Split
|
||||||
```bash
|
|
||||||
npm run db:generate # Generate Prisma client
|
|
||||||
npm run db:push # Push schema to database
|
|
||||||
npm run db:studio # Open Prisma Studio
|
|
||||||
npm run db:seed # Seed database
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Overview
|
The homepage uses a **server component orchestrator** pattern:
|
||||||
|
|
||||||
### Tech Stack
|
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps client sections in `ScrollFadeIn`
|
||||||
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
|
- `app/components/Hero.tsx` — **server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
|
||||||
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`, each wrapping their component in a scoped `NextIntlClientProvider` with only the needed translation keys
|
||||||
- **Theming**: next-themes for dark mode (system/light/dark)
|
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
|
||||||
- **Animations**: Framer Motion 12
|
|
||||||
- **3D**: Three.js + React Three Fiber (shader gradient background)
|
|
||||||
- **Database**: PostgreSQL via Prisma ORM
|
|
||||||
- **Cache**: Redis (optional)
|
|
||||||
- **CMS**: Directus (self-hosted, GraphQL, optional)
|
|
||||||
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
|
||||||
- **i18n**: next-intl (EN + DE)
|
|
||||||
- **Monitoring**: Sentry
|
|
||||||
- **Deployment**: Docker (standalone mode) + Nginx
|
|
||||||
|
|
||||||
### Key Directories
|
### SSR Animation Safety
|
||||||
```
|
|
||||||
app/
|
**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.
|
||||||
[locale]/ # i18n routes (en, de)
|
|
||||||
page.tsx # Homepage sections
|
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.
|
||||||
projects/ # Project listing + detail pages
|
|
||||||
api/ # API routes
|
Framer Motion `AnimatePresence` is fine for modals/overlays that only render after user interaction.
|
||||||
book-reviews/ # Book reviews from Directus
|
|
||||||
hobbies/ # Hobbies from Directus
|
|
||||||
n8n/ # n8n webhook proxies
|
|
||||||
projects/ # Projects (PostgreSQL + Directus)
|
|
||||||
tech-stack/ # Tech stack from Directus
|
|
||||||
components/ # React components
|
|
||||||
lib/
|
|
||||||
directus.ts # Directus GraphQL client (no SDK)
|
|
||||||
auth.ts # Auth + rate limiting
|
|
||||||
translations-loader.ts # i18n loaders for server components
|
|
||||||
prisma/
|
|
||||||
schema.prisma # Database schema
|
|
||||||
messages/
|
|
||||||
en.json # English translations
|
|
||||||
de.json # German translations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Source Fallback Chain
|
### Data Source Fallback Chain
|
||||||
The architecture prioritizes resilience with this fallback hierarchy:
|
|
||||||
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured)
|
|
||||||
2. **PostgreSQL** (for projects, analytics)
|
|
||||||
3. **JSON files** (`messages/*.json`)
|
|
||||||
4. **Hardcoded defaults**
|
|
||||||
5. **Display key itself** (last resort)
|
|
||||||
|
|
||||||
**Critical**: The site never crashes if external services (Directus, PostgreSQL, n8n, Redis) are unavailable. All API routes return graceful fallbacks.
|
Every data fetch degrades gracefully — the site never crashes:
|
||||||
|
|
||||||
|
1. **Directus CMS** → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
|
||||||
|
|
||||||
### CMS Integration (Directus)
|
### CMS Integration (Directus)
|
||||||
- GraphQL calls via `lib/directus.ts` (no Directus SDK)
|
|
||||||
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
|
||||||
- Translations use Directus native system (M2O to `languages`)
|
- Returns `null` on failure (never throws)
|
||||||
- Locale mapping: `en` → `en-US`, `de` → `de-DE`
|
- Locale mapping: `en` → `en-US`, `de` → `de-DE`
|
||||||
- API routes export `runtime='nodejs'`, `dynamic='force-dynamic'` and include a `source` field in JSON responses (`directus|fallback|error`)
|
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and return `source` field (`directus|fallback|error`)
|
||||||
|
|
||||||
### n8n Integration
|
### n8n Integration
|
||||||
- Webhook base URL: `N8N_WEBHOOK_URL` env var
|
|
||||||
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
|
||||||
- All endpoints have rate limiting and 10s timeout protection
|
- Auth: `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||||
- Hardcover reading data cached for 5 minutes
|
- All endpoints have rate limiting and 10s timeout
|
||||||
|
- Hardcover reading data cached 5 minutes
|
||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
### i18n (Internationalization)
|
### i18n
|
||||||
- **Supported locales**: `en` (English), `de` (German)
|
|
||||||
- **Primary source**: Static JSON files in `messages/en.json` and `messages/de.json`
|
|
||||||
- **Optional override**: Directus CMS `messages` collection
|
|
||||||
- **Server components**: Use `getHeroTranslations()`, `getNavTranslations()`, etc. from `lib/translations-loader.ts`
|
|
||||||
- **Client components**: Use `useTranslations("key.path")` from next-intl
|
|
||||||
- **Locale mapping**: Middleware defines `["en", "de"]` which must match `app/[locale]/layout.tsx`
|
|
||||||
|
|
||||||
### Component Patterns
|
- Locales: `en`, `de` — defined in `middleware.ts`, must match `app/[locale]/layout.tsx`
|
||||||
- **Client components**: Mark with `"use client"` for interactive/data-fetching parts
|
- Client components: `useTranslations("key.path")` from `next-intl`
|
||||||
- **Data loading**: Use `useEffect` for client-side fetching on mount
|
- Server components: `getTranslations("key.path")` from `next-intl/server`
|
||||||
- **Animations**: Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
|
- Always add keys to both `messages/en.json` and `messages/de.json`
|
||||||
- **Loading states**: Every async component needs a matching Skeleton component
|
|
||||||
|
|
||||||
### Design System ("Liquid Editorial Bento")
|
### Design System
|
||||||
- **Core palette**: Cream (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`)
|
|
||||||
- **Custom colors**: Prefixed with `liquid-*` (sky, mint, lavender, pink, rose, peach, coral, teal, lime)
|
|
||||||
- **Card style**: Gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`)
|
|
||||||
- **Glassmorphism**: Use `backdrop-blur-sm` with `border-2` and `rounded-xl`
|
|
||||||
- **Typography**: Headlines uppercase, tracking-tighter, with accent point at end
|
|
||||||
- **Layout**: Bento Grid for new features (no floating overlays)
|
|
||||||
|
|
||||||
### File Naming
|
- Custom Tailwind colors: `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`, `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
|
||||||
- **Components**: PascalCase in `app/components/` (e.g., `About.tsx`)
|
- Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`
|
||||||
- **API routes**: kebab-case directories in `app/api/` (e.g., `book-reviews/`)
|
- Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<span className="text-emerald-600">.</span>`)
|
||||||
- **Lib utilities**: kebab-case in `lib/` (e.g., `email-obfuscate.ts`)
|
- Layout: Bento Grid, no floating overlays
|
||||||
|
- Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400`) for body text — contrast ratio must be ≥4.5:1
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
- **Language**: Code in English, user-facing text via i18n
|
|
||||||
- **TypeScript**: No `any` types - use interfaces from `lib/directus.ts` or `app/_ui/`
|
|
||||||
- **Error handling**: All API calls must catch errors with fallbacks
|
|
||||||
- **Error logging**: Only in development mode (`process.env.NODE_ENV === "development"`)
|
|
||||||
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
|
||||||
- **No emojis**: Unless explicitly requested
|
|
||||||
|
|
||||||
### Testing Notes
|
- TypeScript: no `any` — use interfaces from `lib/directus.ts` or `types/`
|
||||||
- **Jest environment**: JSDOM with mocks for `window.matchMedia` and `IntersectionObserver`
|
- Error logging: `console.error` only when `process.env.NODE_ENV === "development"`
|
||||||
- **Playwright**: Uses plain Next.js dev server (no Docker) with `NODE_ENV=development` to avoid Edge runtime issues
|
- File naming: PascalCase components (`About.tsx`), kebab-case API routes (`book-reviews/`), kebab-case lib utils
|
||||||
- **Transform**: ESM modules (react-markdown, remark-*, etc.) are transformed via `transformIgnorePatterns`
|
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||||
- **After UI changes**: Run `npm run test` to verify no regressions
|
- Every async component needs a Skeleton loading state
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Jest with JSDOM; mocks for `window.matchMedia` and `IntersectionObserver` in `jest.setup.ts`
|
||||||
|
- ESM modules transformed via `transformIgnorePatterns` (react-markdown, remark-*, etc.)
|
||||||
|
- Server component tests: `const resolved = await Component({ props }); render(resolved)`
|
||||||
|
- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `<img>` tag
|
||||||
|
|
||||||
### Docker & Deployment
|
### Docker & Deployment
|
||||||
- **Standalone mode**: `next.config.ts` uses `output: "standalone"` for optimized Docker builds
|
|
||||||
- **Branches**: `dev` → staging, `production` → live
|
- `output: "standalone"` in `next.config.ts`
|
||||||
- **CI/CD**: Gitea Actions (`.gitea/workflows/`)
|
- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal on failure), starts server
|
||||||
- **Verify Docker builds**: Always test Docker builds after changes to `next.config.ts` or dependencies
|
- CI/CD: `.gitea/workflows/ci.yml` — `test-build` job (all branches), `deploy-dev` (dev only), `deploy-production` (production only)
|
||||||
|
- Branches: `dev` → testing.dk0.dev, `production` → dk0.dev
|
||||||
|
- Dev and production share the same PostgreSQL and Redis instances
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
### Adding a CMS-managed section
|
### Adding a CMS-managed section
|
||||||
|
|
||||||
1. Define GraphQL query + types in `lib/directus.ts`
|
1. Define GraphQL query + types in `lib/directus.ts`
|
||||||
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
|
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
|
||||||
3. Create component in `app/components/<Name>.tsx`
|
3. Create component in `app/components/<Name>.tsx` with Skeleton loading state
|
||||||
4. Add i18n keys to `messages/en.json` and `messages/de.json`
|
4. Add i18n keys to both `messages/en.json` and `messages/de.json`
|
||||||
5. Integrate into parent component
|
5. Create a `<Name>Client` wrapper in `ClientWrappers.tsx` with scoped `NextIntlClientProvider`
|
||||||
|
6. Add to `HomePageServer.tsx` wrapped in `ScrollFadeIn`
|
||||||
### Adding i18n strings
|
|
||||||
1. Add keys to both `messages/en.json` and `messages/de.json`
|
|
||||||
2. Use `useTranslations("key.path")` in client components
|
|
||||||
3. Use `getTranslations("key.path")` in server components
|
|
||||||
|
|
||||||
### Working with Directus
|
|
||||||
- All queries go through `directusRequest()` in `lib/directus.ts`
|
|
||||||
- Uses GraphQL endpoint (`/graphql`) with 2s timeout
|
|
||||||
- Returns `null` on failure (graceful degradation)
|
|
||||||
- Translations filtered by `languages_code.code` matching Directus locale
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### Required for CMS
|
|
||||||
```bash
|
|
||||||
DIRECTUS_URL=https://cms.dk0.dev
|
|
||||||
DIRECTUS_STATIC_TOKEN=...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required for n8n features
|
|
||||||
```bash
|
|
||||||
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
|
||||||
N8N_SECRET_TOKEN=...
|
|
||||||
N8N_API_KEY=...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database & Cache
|
|
||||||
```bash
|
|
||||||
DATABASE_URL=postgresql://...
|
|
||||||
REDIS_URL=redis://...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional
|
|
||||||
```bash
|
|
||||||
SENTRY_DSN=...
|
|
||||||
NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation References
|
|
||||||
- Operations guide: `docs/OPERATIONS.md`
|
|
||||||
- Locale system: `docs/LOCALE_SYSTEM.md`
|
|
||||||
- CMS guide: `docs/CMS_GUIDE.md`
|
|
||||||
- Testing & deployment: `docs/TESTING_AND_DEPLOYMENT.md`
|
|
||||||
|
|||||||
334
.github/workflows-disabled/ci-cd.yml.disabled
vendored
334
.github/workflows-disabled/ci-cd.yml.disabled
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
|
||||||
|
|
||||||
|
|||||||
183
CLAUDE.md
183
CLAUDE.md
@@ -1,23 +1,24 @@
|
|||||||
# CLAUDE.md - Portfolio Project Guide
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "liquid" design system with soft gradient colors and glassmorphism effects.
|
Personal portfolio website for Dennis Konkol (dk0.dev). Built with Next.js 15 (App Router), TypeScript, Tailwind CSS, and Framer Motion. Uses a "Liquid Editorial Bento" design system with soft gradient colors and glassmorphism effects.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
|
- **Framework**: Next.js 15 (App Router), TypeScript 5.9, React 19
|
||||||
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
||||||
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
|
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
|
||||||
- **Animations**: Framer Motion 12
|
- **Animations**: Framer Motion 12
|
||||||
- **3D**: Three.js + React Three Fiber (shader gradient background)
|
- **3D**: Three.js + React Three Fiber + `@shadergradient/react` (shader gradient background)
|
||||||
- **Database**: PostgreSQL via Prisma ORM
|
- **Database**: PostgreSQL via Prisma ORM
|
||||||
- **Cache**: Redis (optional)
|
- **Cache**: Redis (optional)
|
||||||
- **CMS**: Directus (self-hosted, REST/GraphQL, optional)
|
- **CMS**: Directus (self-hosted, GraphQL only, optional)
|
||||||
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
||||||
- **i18n**: next-intl (EN + DE), message files in `messages/`
|
- **i18n**: next-intl (EN + DE), message files in `messages/`
|
||||||
- **Monitoring**: Sentry
|
- **Deployment**: Docker + Nginx, CI via Gitea Actions (`output: "standalone"`)
|
||||||
- **Deployment**: Docker + Nginx, CI via Gitea Actions
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
@@ -26,76 +27,54 @@ npm run dev # Full dev environment (Docker + Next.js)
|
|||||||
npm run dev:simple # Next.js only (no Docker)
|
npm run dev:simple # Next.js only (no Docker)
|
||||||
npm run dev:next # Plain Next.js dev server
|
npm run dev:next # Plain Next.js dev server
|
||||||
npm run build # Production build
|
npm run build # Production build
|
||||||
npm run lint # ESLint
|
npm run lint # ESLint (0 errors required, warnings OK)
|
||||||
npm run test # Jest unit tests
|
npm run lint:fix # Auto-fix lint issues
|
||||||
|
npm run test # All Jest unit tests
|
||||||
|
npx jest path/to/test.tsx # Run a single test file
|
||||||
|
npm run test:watch # Watch mode
|
||||||
npm run test:e2e # Playwright E2E tests
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
npm run db:generate # Regenerate Prisma client after schema changes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Architecture
|
||||||
|
|
||||||
```
|
### Server/Client Component Split
|
||||||
app/
|
|
||||||
[locale]/ # i18n routes (en, de)
|
|
||||||
page.tsx # Homepage (hero, about, projects, contact)
|
|
||||||
projects/ # Project listing + detail pages
|
|
||||||
api/ # API routes
|
|
||||||
book-reviews/ # Book reviews from Directus CMS
|
|
||||||
content/ # CMS content pages
|
|
||||||
hobbies/ # Hobbies from Directus
|
|
||||||
n8n/ # n8n webhook proxies
|
|
||||||
hardcover/ # Currently reading (Hardcover API via n8n)
|
|
||||||
status/ # Activity status (coding, music, gaming)
|
|
||||||
chat/ # AI chatbot
|
|
||||||
generate-image/ # AI image generation
|
|
||||||
projects/ # Projects API (PostgreSQL + Directus fallback)
|
|
||||||
tech-stack/ # Tech stack from Directus
|
|
||||||
components/ # React components
|
|
||||||
About.tsx # About section (tech stack, hobbies, books)
|
|
||||||
CurrentlyReading.tsx # Currently reading widget (n8n/Hardcover)
|
|
||||||
ReadBooks.tsx # Read books with ratings (Directus CMS)
|
|
||||||
Projects.tsx # Featured projects section
|
|
||||||
Hero.tsx # Hero section
|
|
||||||
Contact.tsx # Contact form
|
|
||||||
lib/
|
|
||||||
directus.ts # Directus GraphQL client (no SDK)
|
|
||||||
auth.ts # Auth utilities + rate limiting
|
|
||||||
prisma/
|
|
||||||
schema.prisma # Database schema
|
|
||||||
messages/
|
|
||||||
en.json # English translations
|
|
||||||
de.json # German translations
|
|
||||||
docs/ # Documentation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture Patterns
|
The homepage uses a **server component orchestrator** pattern:
|
||||||
|
|
||||||
### Data Source Hierarchy (Fallback Chain)
|
- `app/_ui/HomePageServer.tsx` — async server component, fetches all translations in parallel via `Promise.all`, renders Hero directly, wraps below-fold sections in `ScrollFadeIn`
|
||||||
1. Directus CMS (if configured via `DIRECTUS_STATIC_TOKEN`)
|
- `app/components/Hero.tsx` — **server component** (no `"use client"`), uses `getTranslations()` from `next-intl/server`
|
||||||
2. PostgreSQL (for projects, analytics)
|
- `app/components/ClientWrappers.tsx` — exports `AboutClient`, `ProjectsClient`, `ContactClient`, `FooterClient`; each wraps its component in a scoped `NextIntlClientProvider` with only the needed translation namespace
|
||||||
3. JSON files (`messages/*.json`)
|
- `app/components/ClientProviders.tsx` — root client wrapper, defers Three.js/WebGL via `requestIdleCallback` (5s timeout) to avoid blocking LCP
|
||||||
4. Hardcoded defaults
|
|
||||||
5. Display key itself as last resort
|
|
||||||
|
|
||||||
All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable.
|
### SSR Animation Safety
|
||||||
|
|
||||||
|
**Never use Framer Motion's `initial={{ opacity: 0 }}` on SSR-rendered elements** — it bakes `style="opacity:0"` into HTML, making content invisible if JS hydration fails or is slow.
|
||||||
|
|
||||||
|
Use `ScrollFadeIn` (`app/components/ScrollFadeIn.tsx`) instead: renders no inline style during SSR, applies opacity+transform only after `hasMounted` check via IntersectionObserver + CSS transitions.
|
||||||
|
|
||||||
|
`AnimatePresence` is fine for modals/overlays that only render after user interaction.
|
||||||
|
|
||||||
|
### Data Source Fallback Chain
|
||||||
|
|
||||||
|
Every data fetch degrades gracefully — the site never crashes:
|
||||||
|
|
||||||
|
1. **Directus CMS** (if `DIRECTUS_STATIC_TOKEN` configured) → 2. **PostgreSQL** → 3. **JSON files** (`messages/*.json`) → 4. **Hardcoded defaults** → 5. **i18n key itself**
|
||||||
|
|
||||||
### CMS Integration (Directus)
|
### CMS Integration (Directus)
|
||||||
- REST/GraphQL calls via `lib/directus.ts` (no Directus SDK)
|
|
||||||
|
- GraphQL via `lib/directus.ts` — no Directus SDK, uses `directusRequest()` with 2s timeout
|
||||||
|
- Returns `null` on failure, never throws
|
||||||
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
||||||
- Translations use Directus native translation system (M2O to `languages`)
|
- Translations use Directus native M2O system; locale mapping: `en` → `en-US`, `de` → `de-DE`
|
||||||
- Locale mapping: `en` -> `en-US`, `de` -> `de-DE`
|
- API routes must export `runtime = 'nodejs'`, `dynamic = 'force-dynamic'`, and include a `source` field in the response (`"directus"` | `"fallback"` | `"error"`)
|
||||||
|
|
||||||
### n8n Integration
|
### n8n Integration
|
||||||
- Webhook base URL: `N8N_WEBHOOK_URL` env var
|
|
||||||
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
|
||||||
- All n8n endpoints have rate limiting and timeout protection (10s)
|
|
||||||
- Hardcover data cached for 5 minutes
|
|
||||||
|
|
||||||
### Component Patterns
|
- Webhook proxies in `app/api/n8n/` (status, chat, hardcover, generate-image)
|
||||||
- Client components with `"use client"` for interactive/data-fetching parts
|
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||||
- `useEffect` for data loading on mount
|
- All endpoints have rate limiting and 10s timeout
|
||||||
- `useTranslations` from next-intl for i18n
|
- Hardcover reading data cached 5 minutes
|
||||||
- Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
|
|
||||||
- Gradient cards with `liquid-*` color tokens and `backdrop-blur-sm`
|
|
||||||
|
|
||||||
## Design System
|
## Design System
|
||||||
|
|
||||||
@@ -103,54 +82,54 @@ Custom Tailwind colors prefixed with `liquid-`:
|
|||||||
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
|
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
|
||||||
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
|
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
|
||||||
|
|
||||||
Cards use gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`) with `border-2` and `rounded-xl`.
|
Cards: `bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15` with `backdrop-blur-sm`, `border-2`, `rounded-xl`.
|
||||||
|
|
||||||
|
Typography: Headlines uppercase, `tracking-tighter`, accent dot at end (`<span className="text-emerald-600">.</span>`).
|
||||||
|
|
||||||
|
Accessibility: Use `text-stone-600 dark:text-stone-400` (not `text-stone-400` alone) for body text — contrast ratio must be ≥4.5:1.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **TypeScript**: No `any` — use interfaces from `lib/directus.ts` or `types/`
|
||||||
|
- **Components**: PascalCase files in `app/components/`; every async component needs a Skeleton loading state
|
||||||
|
- **API routes**: kebab-case directories in `app/api/`
|
||||||
|
- **i18n**: Always add keys to both `messages/en.json` and `messages/de.json`; `useTranslations()` in client, `getTranslations()` in server components
|
||||||
|
- **Error logging**: `console.error` only when `process.env.NODE_ENV === "development"`
|
||||||
|
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||||
|
- **No emojis** in code unless explicitly requested
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
- Jest with JSDOM; `jest.setup.ts` mocks `window.matchMedia`, `IntersectionObserver`, and `NextResponse`
|
||||||
|
- ESM modules (react-markdown, remark-*, etc.) handled via `transformIgnorePatterns` in `jest.config.ts`
|
||||||
|
- Server component tests: `const resolved = await Component({ props }); render(resolved)`
|
||||||
|
- Test mocks for `next/image`: use `eslint-disable-next-line @next/next/no-img-element` on the `<img>` tag
|
||||||
|
|
||||||
|
## Deployment & CI/CD
|
||||||
|
|
||||||
|
- `output: "standalone"` in `next.config.ts`
|
||||||
|
- Entrypoint: `scripts/start-with-migrate.js` — waits for DB, runs migrations (non-fatal), starts server
|
||||||
|
- CI/CD: `.gitea/workflows/ci.yml` — `test-build` (all branches), `deploy-dev` (dev branch only), `deploy-production` (production branch only)
|
||||||
|
- **Branches**: `dev` → testing.dk0.dev | `production` → dk0.dev
|
||||||
|
- Dev and production share the same PostgreSQL and Redis instances
|
||||||
|
|
||||||
## Key Environment Variables
|
## Key Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required for CMS
|
|
||||||
DIRECTUS_URL=https://cms.dk0.dev
|
DIRECTUS_URL=https://cms.dk0.dev
|
||||||
DIRECTUS_STATIC_TOKEN=...
|
DIRECTUS_STATIC_TOKEN=...
|
||||||
|
|
||||||
# Required for n8n features
|
|
||||||
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
||||||
N8N_SECRET_TOKEN=...
|
N8N_SECRET_TOKEN=...
|
||||||
N8N_API_KEY=...
|
N8N_API_KEY=...
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://...
|
DATABASE_URL=postgresql://...
|
||||||
|
REDIS_URL=redis://... # optional
|
||||||
# Optional
|
|
||||||
REDIS_URL=redis://...
|
|
||||||
SENTRY_DSN=...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Adding a CMS-managed Section
|
||||||
|
|
||||||
- Language: Code in English, user-facing text via i18n (EN + DE)
|
1. Define GraphQL query + types in `lib/directus.ts`
|
||||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
2. Create API route `app/api/<name>/route.ts` with `runtime='nodejs'`, `dynamic='force-dynamic'`, and `source` field in response
|
||||||
- Components: PascalCase files in `app/components/`
|
3. Create component `app/components/<Name>.tsx` with Skeleton loading state
|
||||||
- API routes: kebab-case directories in `app/api/`
|
4. Add i18n keys to both `messages/en.json` and `messages/de.json`
|
||||||
- CMS data always has a static fallback - never rely solely on Directus
|
5. Create `<Name>Client` wrapper in `app/components/ClientWrappers.tsx` with scoped `NextIntlClientProvider`
|
||||||
- Error logging: Only in `development` mode (`process.env.NODE_ENV === "development"`)
|
6. Add to `app/_ui/HomePageServer.tsx` wrapped in `<ScrollFadeIn>`
|
||||||
- No emojis in code unless explicitly requested
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Adding a new CMS-managed section
|
|
||||||
1. Define the GraphQL query + types in `lib/directus.ts`
|
|
||||||
2. Create an API route in `app/api/<name>/route.ts`
|
|
||||||
3. Create a component in `app/components/<Name>.tsx`
|
|
||||||
4. Add i18n keys to `messages/en.json` and `messages/de.json`
|
|
||||||
5. Integrate into the parent component (usually `About.tsx`)
|
|
||||||
|
|
||||||
### Adding i18n strings
|
|
||||||
1. Add keys to `messages/en.json` and `messages/de.json`
|
|
||||||
2. Access via `useTranslations("key.path")` in client components
|
|
||||||
3. Or `getTranslations("key.path")` in server components
|
|
||||||
|
|
||||||
### Working with Directus collections
|
|
||||||
- All queries go through `directusRequest()` in `lib/directus.ts`
|
|
||||||
- Uses GraphQL endpoint (`/graphql`)
|
|
||||||
- 2-second timeout, graceful null fallback
|
|
||||||
- Translations filtered by `languages_code.code` matching Directus locale
|
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('ActivityFeed NaN Handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should convert gaming.name to string safely', () => {
|
it('should convert gaming.name to string safely', () => {
|
||||||
const validName = String('Test Game' || '');
|
const validName = String('Test Game');
|
||||||
expect(validName).toBe('Test Game');
|
expect(validName).toBe('Test Game');
|
||||||
expect(typeof validName).toBe('string');
|
expect(typeof validName).toBe('string');
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ jest.mock("next-intl", () => ({
|
|||||||
// Mock next/image
|
// Mock next/image
|
||||||
jest.mock("next/image", () => ({
|
jest.mock("next/image", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ describe('Header', () => {
|
|||||||
render(<Header />);
|
render(<Header />);
|
||||||
expect(screen.getByText('dk')).toBeInTheDocument();
|
expect(screen.getByText('dk')).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for navigation links
|
// Check for navigation links (appear in both desktop and mobile menus)
|
||||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
expect(screen.getAllByText('Home').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('About')).toBeInTheDocument();
|
expect(screen.getAllByText('About').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('Projects')).toBeInTheDocument();
|
expect(screen.getAllByText('Projects').length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText('Contact')).toBeInTheDocument();
|
expect(screen.getAllByText('Contact').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Hero from '@/app/components/Hero';
|
import Hero from '@/app/components/Hero';
|
||||||
|
|
||||||
// Mock next-intl
|
// Mock next-intl/server
|
||||||
jest.mock('next-intl', () => ({
|
jest.mock('next-intl/server', () => ({
|
||||||
useLocale: () => 'en',
|
getTranslations: () => Promise.resolve((key: string) => {
|
||||||
useTranslations: () => (key: string) => {
|
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
|
badge: 'Student & Self-Hoster',
|
||||||
|
line1: 'Building',
|
||||||
|
line2: 'Stuff.',
|
||||||
description: 'Dennis is a student and passionate self-hoster.',
|
description: 'Dennis is a student and passionate self-hoster.',
|
||||||
ctaWork: 'View My Work'
|
ctaWork: 'View My Work',
|
||||||
|
ctaContact: 'Get in touch',
|
||||||
};
|
};
|
||||||
return messages[key] || key;
|
return messages[key] || key;
|
||||||
},
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock next/image
|
// Mock next/image
|
||||||
@@ -25,6 +28,7 @@ interface ImageProps {
|
|||||||
jest.mock('next/image', () => ({
|
jest.mock('next/image', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
|
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
@@ -36,8 +40,9 @@ jest.mock('next/image', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('Hero', () => {
|
describe('Hero', () => {
|
||||||
it('renders the hero section correctly', () => {
|
it('renders the hero section correctly', async () => {
|
||||||
render(<Hero />);
|
const HeroResolved = await Hero({ locale: 'en' });
|
||||||
|
render(HeroResolved);
|
||||||
|
|
||||||
// Check for the main headlines (defaults in Hero.tsx)
|
// Check for the main headlines (defaults in Hero.tsx)
|
||||||
expect(screen.getByText('Building')).toBeInTheDocument();
|
expect(screen.getByText('Building')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||||
|
|
||||||
// Mock next-themes
|
// Mock custom ThemeProvider
|
||||||
jest.mock("next-themes", () => ({
|
jest.mock("@/app/components/ThemeProvider", () => ({
|
||||||
useTheme: () => ({
|
useTheme: () => ({
|
||||||
theme: "light",
|
theme: "light",
|
||||||
setTheme: jest.fn(),
|
setTheme: jest.fn(),
|
||||||
}),
|
}),
|
||||||
|
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("ThemeToggle Component", () => {
|
describe("ThemeToggle Component", () => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function HomePage() {
|
|||||||
{/* Spacer to prevent navbar overlap */}
|
{/* Spacer to prevent navbar overlap */}
|
||||||
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
|
<div className="h-16 sm:h-24 md:h-32" aria-hidden="true"></div>
|
||||||
<main className="relative">
|
<main className="relative">
|
||||||
<Hero />
|
<Hero locale="en" />
|
||||||
|
|
||||||
{/* Wavy Separator 1 - Hero to About */}
|
{/* Wavy Separator 1 - Hero to About */}
|
||||||
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
<div className="relative h-12 sm:h-16 md:h-24 overflow-hidden">
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import Header from "../components/Header.server";
|
import Header from "../components/Header.server";
|
||||||
|
import Hero from "../components/Hero";
|
||||||
|
import ScrollFadeIn from "../components/ScrollFadeIn";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import {
|
import {
|
||||||
getHeroTranslations,
|
|
||||||
getAboutTranslations,
|
getAboutTranslations,
|
||||||
getProjectsTranslations,
|
getProjectsTranslations,
|
||||||
getContactTranslations,
|
getContactTranslations,
|
||||||
getFooterTranslations,
|
getFooterTranslations,
|
||||||
} from "@/lib/translations-loader";
|
} from "@/lib/translations-loader";
|
||||||
import {
|
import {
|
||||||
HeroClient,
|
|
||||||
AboutClient,
|
AboutClient,
|
||||||
ProjectsClient,
|
ProjectsClient,
|
||||||
ContactClient,
|
ContactClient,
|
||||||
@@ -20,9 +20,8 @@ interface HomePageServerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||||
// Parallel laden aller Translations
|
// Parallel laden aller Translations (hero translations handled by Hero server component)
|
||||||
const [heroT, aboutT, projectsT, contactT, footerT] = await Promise.all([
|
const [aboutT, projectsT, contactT, footerT] = await Promise.all([
|
||||||
getHeroTranslations(locale),
|
|
||||||
getAboutTranslations(locale),
|
getAboutTranslations(locale),
|
||||||
getProjectsTranslations(locale),
|
getProjectsTranslations(locale),
|
||||||
getContactTranslations(locale),
|
getContactTranslations(locale),
|
||||||
@@ -57,7 +56,7 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
{/* Spacer to prevent navbar overlap */}
|
{/* Spacer to prevent navbar overlap */}
|
||||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||||
<main className="relative">
|
<main className="relative">
|
||||||
<HeroClient locale={locale} translations={heroT} />
|
<Hero locale={locale} />
|
||||||
|
|
||||||
{/* Wavy Separator 1 - Hero to About */}
|
{/* Wavy Separator 1 - Hero to About */}
|
||||||
<div className="relative h-24 overflow-hidden">
|
<div className="relative h-24 overflow-hidden">
|
||||||
@@ -80,7 +79,9 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScrollFadeIn>
|
||||||
<AboutClient locale={locale} translations={aboutT} />
|
<AboutClient locale={locale} translations={aboutT} />
|
||||||
|
</ScrollFadeIn>
|
||||||
|
|
||||||
{/* Wavy Separator 2 - About to Projects */}
|
{/* Wavy Separator 2 - About to Projects */}
|
||||||
<div className="relative h-24 overflow-hidden">
|
<div className="relative h-24 overflow-hidden">
|
||||||
@@ -103,7 +104,9 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScrollFadeIn>
|
||||||
<ProjectsClient locale={locale} translations={projectsT} />
|
<ProjectsClient locale={locale} translations={projectsT} />
|
||||||
|
</ScrollFadeIn>
|
||||||
|
|
||||||
{/* Wavy Separator 3 - Projects to Contact */}
|
{/* Wavy Separator 3 - Projects to Contact */}
|
||||||
<div className="relative h-24 overflow-hidden">
|
<div className="relative h-24 overflow-hidden">
|
||||||
@@ -126,9 +129,13 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ScrollFadeIn>
|
||||||
<ContactClient locale={locale} translations={contactT} />
|
<ContactClient locale={locale} translations={contactT} />
|
||||||
|
</ScrollFadeIn>
|
||||||
</main>
|
</main>
|
||||||
|
<ScrollFadeIn>
|
||||||
<FooterClient locale={locale} translations={footerT} />
|
<FooterClient locale={locale} translations={footerT} />
|
||||||
|
</ScrollFadeIn>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,7 @@ export default function ProjectDetailClient({
|
|||||||
setCanGoBack(true);
|
setCanGoBack(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
}, []);
|
||||||
navigator.sendBeacon?.(
|
|
||||||
"/api/analytics/track",
|
|
||||||
new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}, [project.id, project.slug, locale]);
|
|
||||||
|
|
||||||
const handleBack = (e: React.MouseEvent) => {
|
const handleBack = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,9 @@ import { getBookReviews } from '@/lib/directus';
|
|||||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/book-reviews
|
* GET /api/book-reviews
|
||||||
@@ -31,25 +33,23 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reviews && reviews.length > 0) {
|
if (reviews && reviews.length > 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
bookReviews: reviews,
|
{ bookReviews: reviews, source: 'directus' },
|
||||||
source: 'directus'
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
bookReviews: null,
|
{ bookReviews: null, source: 'fallback' },
|
||||||
source: 'fallback'
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('Error loading book reviews:', error);
|
console.error('Error loading book reviews:', error);
|
||||||
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ bookReviews: null, error: 'Failed to load book reviews', source: 'error' },
|
||||||
bookReviews: null,
|
|
||||||
error: 'Failed to load book reviews',
|
|
||||||
source: 'error'
|
|
||||||
},
|
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
const html = directusPage.content ? richTextToSafeHtml(directusPage.content as any) : "";
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
content: {
|
content: {
|
||||||
title: directusPage.title,
|
title: directusPage.title,
|
||||||
slug: directusPage.slug,
|
slug: directusPage.slug,
|
||||||
locale: directusPage.locale || locale,
|
locale: directusPage.locale || locale,
|
||||||
content: directusPage.content,
|
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 style="margin-top:10px;">
|
|
||||||
<div style="font-size:22px;font-weight:900;letter-spacing:-0.02em;color:${BRAND.bg};">${escapeHtml(opts.title)}</div>
|
|
||||||
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">${escapeHtml(opts.subtitle)} • ${sentAt}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="height:3px;background:${BRAND.mint};margin-top:18px;border-radius:999px;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding:26px;">
|
<!-- Body -->
|
||||||
|
<div style="padding:28px;">
|
||||||
${opts.bodyHtml}
|
${opts.bodyHtml}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="padding:18px 26px;background:${BRAND.bg};border-top:1px solid ${BRAND.border};">
|
<!-- Footer -->
|
||||||
<div style="font-size:12px;color:${BRAND.muted};line-height:1.5;">
|
<div style="padding:16px 28px;background:#0c0c0c;border-top:1px solid #1a1a1a;">
|
||||||
Automatisch generiert von <a href="${BRAND.siteUrl}" style="color:${BRAND.text};text-decoration:underline;">dk0.dev</a> •
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
|
||||||
<a href="mailto:${BRAND.email}" style="color:${BRAND.text};text-decoration:underline;">${BRAND.email}</a>
|
<div style="font-size:11px;color:#374151;">
|
||||||
|
<a href="${B.siteUrl}" style="color:#4b5563;text-decoration:none;">${B.siteUrl}</a>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:11px;color:#374151;">
|
||||||
|
<a href="mailto:${B.email}" style="color:#4b5563;text-decoration:none;">${B.email}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>`;
|
||||||
`.trim();
|
}
|
||||||
|
|
||||||
|
function messageCard(label: string, html: string, accentColor: string = B.mint): string {
|
||||||
|
return `
|
||||||
|
<div style="background:#0f0f0f;border:1px solid #1e1e1e;border-left:3px solid ${accentColor};border-radius:0 12px 12px 0;overflow:hidden;">
|
||||||
|
<div style="padding:10px 16px;background:#161616;border-bottom:1px solid #1e1e1e;">
|
||||||
|
<span style="font-size:10px;letter-spacing:0.14em;text-transform:uppercase;font-weight:700;color:#4b5563;">${label}</span>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px 18px;font-size:15px;line-height:1.75;color:#d1d5db;">${html}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctaButton(text: string, href: string): string {
|
||||||
|
return `
|
||||||
|
<div style="margin-top:24px;text-align:center;">
|
||||||
|
<a href="${href}" style="display:inline-block;background:linear-gradient(135deg,${B.mint},${B.sky});color:#111;text-decoration:none;padding:14px 32px;border-radius:12px;font-weight:800;font-size:15px;letter-spacing:-0.01em;">
|
||||||
|
${text}
|
||||||
|
</a>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailTemplates = {
|
const emailTemplates = {
|
||||||
@@ -85,31 +110,16 @@ const emailTemplates = {
|
|||||||
subject: "Vielen Dank für deine Nachricht! 👋",
|
subject: "Vielen Dank für deine Nachricht! 👋",
|
||||||
template: (name: string, originalMessage: string) => {
|
template: (name: string, originalMessage: string) => {
|
||||||
const safeName = escapeHtml(name);
|
const safeName = escapeHtml(name);
|
||||||
const safeMsg = nl2br(escapeHtml(originalMessage));
|
|
||||||
return baseEmail({
|
return baseEmail({
|
||||||
title: `Danke, ${safeName}!`,
|
title: `Danke, ${safeName}!`,
|
||||||
subtitle: "Nachricht erhalten",
|
preheader: "Nachricht erhalten",
|
||||||
bodyHtml: `
|
bodyHtml: `
|
||||||
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
||||||
Hey ${safeName},<br><br>
|
Hey ${safeName},<br><br>
|
||||||
danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück.
|
danke für deine Nachricht — ich habe sie erhalten und melde mich so schnell wie möglich bei dir zurück. 🙌
|
||||||
</div>
|
</p>
|
||||||
|
${messageCard("Deine Nachricht", nl2br(originalMessage))}
|
||||||
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
|
||||||
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
||||||
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
||||||
${safeMsg}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:20px;text-align:center;">
|
|
||||||
<a href="${BRAND.siteUrl}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
|
|
||||||
Portfolio ansehen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`.trim(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -117,31 +127,16 @@ const emailTemplates = {
|
|||||||
subject: "Projekt-Anfrage erhalten! 🚀",
|
subject: "Projekt-Anfrage erhalten! 🚀",
|
||||||
template: (name: string, originalMessage: string) => {
|
template: (name: string, originalMessage: string) => {
|
||||||
const safeName = escapeHtml(name);
|
const safeName = escapeHtml(name);
|
||||||
const safeMsg = nl2br(escapeHtml(originalMessage));
|
|
||||||
return baseEmail({
|
return baseEmail({
|
||||||
title: `Projekt-Anfrage: danke, ${safeName}!`,
|
title: `Projekt-Anfrage: danke, ${safeName}!`,
|
||||||
subtitle: "Ich melde mich zeitnah",
|
preheader: "Ich melde mich zeitnah",
|
||||||
bodyHtml: `
|
bodyHtml: `
|
||||||
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
||||||
Hey ${safeName},<br><br>
|
Hey ${safeName},<br><br>
|
||||||
mega — danke für die Projekt-Anfrage. Ich schaue mir deine Nachricht an und komme mit Rückfragen/Ideen auf dich zu.
|
mega — danke für die Projekt-Anfrage! Ich schaue mir alles an und melde mich bald mit Ideen und Rückfragen. 🚀
|
||||||
</div>
|
</p>
|
||||||
|
${messageCard("Deine Projekt-Anfrage", nl2br(originalMessage), B.sky)}
|
||||||
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
${ctaButton("Mein Portfolio ansehen →", B.siteUrl)}`,
|
||||||
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
||||||
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Projekt-Nachricht</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
||||||
${safeMsg}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:20px;text-align:center;">
|
|
||||||
<a href="mailto:${BRAND.email}" style="display:inline-block;background:${BRAND.text};color:${BRAND.bg};text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
|
|
||||||
Kontakt aufnehmen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
`.trim(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -149,25 +144,15 @@ const emailTemplates = {
|
|||||||
subject: "Danke für deine Nachricht! ⚡",
|
subject: "Danke für deine Nachricht! ⚡",
|
||||||
template: (name: string, originalMessage: string) => {
|
template: (name: string, originalMessage: string) => {
|
||||||
const safeName = escapeHtml(name);
|
const safeName = escapeHtml(name);
|
||||||
const safeMsg = nl2br(escapeHtml(originalMessage));
|
|
||||||
return baseEmail({
|
return baseEmail({
|
||||||
title: `Danke, ${safeName}!`,
|
title: `Danke, ${safeName}!`,
|
||||||
subtitle: "Kurze Bestätigung",
|
preheader: "Kurze Bestätigung",
|
||||||
bodyHtml: `
|
bodyHtml: `
|
||||||
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
||||||
Hey ${safeName},<br><br>
|
Hey ${safeName},<br><br>
|
||||||
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück.
|
kurze Bestätigung: deine Nachricht ist angekommen. Ich melde mich bald zurück. ⚡
|
||||||
</div>
|
</p>
|
||||||
|
${messageCard("Deine Nachricht", nl2br(originalMessage))}`,
|
||||||
<div style="margin-top:18px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
||||||
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
||||||
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine Nachricht</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
||||||
${safeMsg}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`.trim(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -175,35 +160,19 @@ const emailTemplates = {
|
|||||||
subject: "Antwort auf deine Nachricht 📧",
|
subject: "Antwort auf deine Nachricht 📧",
|
||||||
template: (name: string, originalMessage: string, responseMessage: string) => {
|
template: (name: string, originalMessage: string, responseMessage: string) => {
|
||||||
const safeName = escapeHtml(name);
|
const safeName = escapeHtml(name);
|
||||||
const safeOriginal = nl2br(escapeHtml(originalMessage));
|
|
||||||
const safeResponse = nl2br(escapeHtml(responseMessage));
|
|
||||||
return baseEmail({
|
return baseEmail({
|
||||||
title: `Antwort für ${safeName}`,
|
title: `Hey ${safeName}!`,
|
||||||
subtitle: "Neue Nachricht",
|
preheader: "Antwort von Dennis",
|
||||||
bodyHtml: `
|
bodyHtml: `
|
||||||
<div style="font-size:15px;line-height:1.65;color:${BRAND.text};">
|
<p style="font-size:15px;line-height:1.7;color:#d1d5db;margin:0 0 20px;">
|
||||||
Hey ${safeName},<br><br>
|
Hey ${safeName},<br><br>
|
||||||
hier ist meine Antwort:
|
ich habe mir deine Nachricht angeschaut — hier ist meine Antwort:
|
||||||
|
</p>
|
||||||
|
${messageCard("Antwort von Dennis", nl2br(responseMessage), B.mint)}
|
||||||
|
<div style="margin-top:16px;">
|
||||||
|
${messageCard("Deine ursprüngliche Nachricht", nl2br(originalMessage), "#2a2a2a")}
|
||||||
</div>
|
</div>
|
||||||
|
${ctaButton("Portfolio ansehen →", B.siteUrl)}`,
|
||||||
<div style="margin-top:14px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
||||||
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
||||||
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Antwort</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.mint};">
|
|
||||||
${safeResponse}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top:16px;background:${BRAND.bg};border:1px solid ${BRAND.border};border-radius:16px;overflow:hidden;">
|
|
||||||
<div style="padding:14px 16px;background:${BRAND.sand};border-bottom:1px solid ${BRAND.border};">
|
|
||||||
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">Deine ursprüngliche Nachricht</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;line-height:1.65;color:${BRAND.text};font-size:14px;border-left:4px solid ${BRAND.border};">
|
|
||||||
${safeOriginal}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`.trim(),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -234,33 +203,20 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const { to, name, template, originalMessage, response } = body;
|
const { to, name, template, originalMessage, response } = body;
|
||||||
|
|
||||||
// Validate input
|
|
||||||
if (!to || !name || !template || !originalMessage) {
|
if (!to || !name || !template || !originalMessage) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
|
||||||
{ error: "Alle Felder sind erforderlich" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (template === "reply" && (!response || !response.trim())) {
|
if (template === "reply" && (!response || !response.trim())) {
|
||||||
return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 });
|
return NextResponse.json({ error: "Antworttext ist erforderlich" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(to)) {
|
if (!emailRegex.test(to)) {
|
||||||
console.error('❌ Validation failed: Invalid email format');
|
return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Ungültige E-Mail-Adresse" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if template exists
|
|
||||||
if (!emailTemplates[template]) {
|
if (!emailTemplates[template]) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Ungültiges Template" }, { status: 400 });
|
||||||
{ error: "Ungültiges Template" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = process.env.MY_EMAIL ?? "";
|
const user = process.env.MY_EMAIL ?? "";
|
||||||
@@ -268,10 +224,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!user || !pass) {
|
if (!user || !pass) {
|
||||||
console.error("❌ Missing email/password environment variables");
|
console.error("❌ Missing email/password environment variables");
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
|
||||||
{ error: "E-Mail-Server nicht konfiguriert" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const transportOptions: SMTPTransport.Options = {
|
const transportOptions: SMTPTransport.Options = {
|
||||||
@@ -279,86 +232,50 @@ export async function POST(request: NextRequest) {
|
|||||||
port: 587,
|
port: 587,
|
||||||
secure: false,
|
secure: false,
|
||||||
requireTLS: true,
|
requireTLS: true,
|
||||||
auth: {
|
auth: { type: "login", user, pass },
|
||||||
type: "login",
|
|
||||||
user,
|
|
||||||
pass,
|
|
||||||
},
|
|
||||||
connectionTimeout: 30000,
|
connectionTimeout: 30000,
|
||||||
greetingTimeout: 30000,
|
greetingTimeout: 30000,
|
||||||
socketTimeout: 60000,
|
socketTimeout: 60000,
|
||||||
tls: {
|
tls: { rejectUnauthorized: false, ciphers: 'SSLv3' },
|
||||||
rejectUnauthorized: false,
|
|
||||||
ciphers: 'SSLv3'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const transport = nodemailer.createTransport(transportOptions);
|
const transport = nodemailer.createTransport(transportOptions);
|
||||||
|
|
||||||
// Verify transport configuration
|
|
||||||
try {
|
try {
|
||||||
await transport.verify();
|
await transport.verify();
|
||||||
} catch (_verifyError) {
|
} catch {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
|
||||||
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTemplate = emailTemplates[template];
|
const selectedTemplate = emailTemplates[template];
|
||||||
let html: string;
|
const html = template === "reply"
|
||||||
if (template === "reply") {
|
? emailTemplates.reply.template(name, originalMessage, response || "")
|
||||||
html = emailTemplates.reply.template(name, originalMessage, response || "");
|
: emailTemplates[template as Exclude<typeof template, "reply">].template(name, originalMessage);
|
||||||
} else {
|
|
||||||
// Narrow the template type so TS knows this is not the 3-arg reply template
|
|
||||||
const nonReplyTemplate = template as Exclude<typeof template, "reply">;
|
|
||||||
html = emailTemplates[nonReplyTemplate].template(name, originalMessage);
|
|
||||||
}
|
|
||||||
const mailOptions: Mail.Options = {
|
const mailOptions: Mail.Options = {
|
||||||
from: `"Dennis Konkol" <${user}>`,
|
from: `"Dennis Konkol" <${user}>`,
|
||||||
to: to,
|
to,
|
||||||
replyTo: "contact@dk0.dev",
|
replyTo: B.email,
|
||||||
subject: selectedTemplate.subject,
|
subject: selectedTemplate.subject,
|
||||||
html,
|
html,
|
||||||
text: `
|
text: template === "reply"
|
||||||
Hallo ${name}!
|
? `Hey ${name}!\n\nAntwort:\n${response}\n\nDeine ursprüngliche Nachricht:\n${originalMessage}\n\n-- Dennis Konkol\n${B.siteUrl}`
|
||||||
|
: `Hey ${name}!\n\nDanke für deine Nachricht:\n${originalMessage}\n\nIch melde mich bald!\n\n-- Dennis Konkol\n${B.siteUrl}`,
|
||||||
Vielen Dank für deine Nachricht:
|
|
||||||
${originalMessage}
|
|
||||||
|
|
||||||
${template === "reply" ? `\nAntwort:\n${response || ""}\n` : "\nIch werde mich so schnell wie möglich bei dir melden.\n"}
|
|
||||||
|
|
||||||
Beste Grüße,
|
|
||||||
Dennis Konkol
|
|
||||||
Software Engineer & Student
|
|
||||||
https://dki.one
|
|
||||||
contact@dk0.dev
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMailPromise = () =>
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
new Promise<string>((resolve, reject) => {
|
transport.sendMail(mailOptions, (err, info) => {
|
||||||
transport.sendMail(mailOptions, function (err, info) {
|
if (!err) resolve(info.response);
|
||||||
if (!err) {
|
else reject(err.message);
|
||||||
resolve(info.response);
|
|
||||||
} else {
|
|
||||||
reject(err.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await sendMailPromise();
|
return NextResponse.json({ message: "E-Mail erfolgreich gesendet", template, messageId: result });
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "Template-E-Mail erfolgreich gesendet",
|
|
||||||
template: template,
|
|
||||||
messageId: result
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: "Fehler beim Senden der Template-E-Mail",
|
error: "Fehler beim Senden der E-Mail",
|
||||||
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,48 +149,26 @@ export async function POST(request: NextRequest) {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sanitize and validate input
|
|
||||||
const email = sanitizeInput(body.email || '', 255);
|
const email = sanitizeInput(body.email || '', 255);
|
||||||
const name = sanitizeInput(body.name || '', 100);
|
const name = sanitizeInput(body.name || '', 100);
|
||||||
const subject = sanitizeInput(body.subject || '', 200);
|
const subject = sanitizeInput(body.subject || '', 200);
|
||||||
const message = sanitizeInput(body.message || '', 5000);
|
const message = sanitizeInput(body.message || '', 5000);
|
||||||
|
|
||||||
// Email request received
|
|
||||||
|
|
||||||
// Validate input
|
|
||||||
if (!email || !name || !subject || !message) {
|
if (!email || !name || !subject || !message) {
|
||||||
console.error('❌ Validation failed: Missing required fields');
|
return NextResponse.json({ error: "Alle Felder sind erforderlich" }, { status: 400 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Alle Felder sind erforderlich" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(email)) {
|
if (!emailRegex.test(email)) {
|
||||||
console.error('❌ Validation failed: Invalid email format');
|
return NextResponse.json({ error: "Ungültige E-Mail-Adresse" }, { status: 400 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Ungültige E-Mail-Adresse" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message length
|
|
||||||
if (message.length < 10) {
|
if (message.length < 10) {
|
||||||
console.error('❌ Validation failed: Message too short');
|
return NextResponse.json({ error: "Nachricht muss mindestens 10 Zeichen lang sein" }, { status: 400 });
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Nachricht muss mindestens 10 Zeichen lang sein" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate field lengths
|
|
||||||
if (name.length > 100 || subject.length > 200 || message.length > 5000) {
|
if (name.length > 100 || subject.length > 200 || message.length > 5000) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Eingabe zu lang" }, { status: 400 });
|
||||||
{ error: "Eingabe zu lang" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = process.env.MY_EMAIL ?? "";
|
const user = process.env.MY_EMAIL ?? "";
|
||||||
@@ -95,265 +176,98 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
if (!user || !pass) {
|
if (!user || !pass) {
|
||||||
console.error("❌ Missing email/password environment variables");
|
console.error("❌ Missing email/password environment variables");
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "E-Mail-Server nicht konfiguriert" }, { status: 500 });
|
||||||
{ error: "E-Mail-Server nicht konfiguriert" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const transportOptions: SMTPTransport.Options = {
|
const transportOptions: SMTPTransport.Options = {
|
||||||
host: "mail.dk0.dev",
|
host: "mail.dk0.dev",
|
||||||
port: 587,
|
port: 587,
|
||||||
secure: false, // Port 587 uses STARTTLS, not SSL/TLS
|
secure: false,
|
||||||
requireTLS: true,
|
requireTLS: true,
|
||||||
auth: {
|
auth: { type: "login", user, pass },
|
||||||
type: "login",
|
connectionTimeout: 30000,
|
||||||
user,
|
greetingTimeout: 30000,
|
||||||
pass,
|
socketTimeout: 60000,
|
||||||
},
|
|
||||||
// Increased timeout settings for better reliability
|
|
||||||
connectionTimeout: 30000, // 30 seconds
|
|
||||||
greetingTimeout: 30000, // 30 seconds
|
|
||||||
socketTimeout: 60000, // 60 seconds
|
|
||||||
// TLS hardening (allow insecure/self-signed only when explicitly enabled)
|
|
||||||
tls:
|
tls:
|
||||||
process.env.SMTP_ALLOW_INSECURE_TLS === "true" ||
|
process.env.SMTP_ALLOW_INSECURE_TLS === "true" || process.env.SMTP_ALLOW_SELF_SIGNED === "true"
|
||||||
process.env.SMTP_ALLOW_SELF_SIGNED === "true"
|
|
||||||
? { rejectUnauthorized: false }
|
? { rejectUnauthorized: false }
|
||||||
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
|
: { rejectUnauthorized: true, minVersion: "TLSv1.2" },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Creating transport with configured options
|
|
||||||
|
|
||||||
const transport = nodemailer.createTransport(transportOptions);
|
const transport = nodemailer.createTransport(transportOptions);
|
||||||
|
|
||||||
// Verify transport configuration with retry logic
|
|
||||||
let verificationAttempts = 0;
|
let verificationAttempts = 0;
|
||||||
const maxVerificationAttempts = 3;
|
while (verificationAttempts < 3) {
|
||||||
let verificationSuccess = false;
|
|
||||||
|
|
||||||
while (verificationAttempts < maxVerificationAttempts && !verificationSuccess) {
|
|
||||||
try {
|
try {
|
||||||
verificationAttempts++;
|
verificationAttempts++;
|
||||||
await transport.verify();
|
await transport.verify();
|
||||||
verificationSuccess = true;
|
break;
|
||||||
} catch (verifyError) {
|
} catch (verifyError) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
|
console.error(`SMTP verification attempt ${verificationAttempts} failed:`, verifyError);
|
||||||
}
|
}
|
||||||
|
if (verificationAttempts >= 3) {
|
||||||
if (verificationAttempts >= maxVerificationAttempts) {
|
return NextResponse.json({ error: "E-Mail-Server-Verbindung fehlgeschlagen" }, { status: 500 });
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('All SMTP verification attempts failed');
|
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "E-Mail-Server-Verbindung fehlgeschlagen" },
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before retry
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandUrl = "https://dk0.dev";
|
|
||||||
const sentAt = new Date().toLocaleString('de-DE', {
|
const sentAt = new Date().toLocaleString('de-DE', {
|
||||||
year: 'numeric',
|
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const safeName = escapeHtml(name);
|
|
||||||
const safeEmail = escapeHtml(email);
|
|
||||||
const safeSubject = escapeHtml(subject);
|
|
||||||
const safeMessageHtml = escapeHtml(message).replace(/\n/g, "<br>");
|
|
||||||
const initial = (name.trim()[0] || "?").toUpperCase();
|
const initial = (name.trim()[0] || "?").toUpperCase();
|
||||||
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
|
const replyHref = `mailto:${email}?subject=${encodeURIComponent(`Re: ${subject}`)}`;
|
||||||
|
const messageHtml = escapeHtml(message).replace(/\n/g, "<br>");
|
||||||
|
|
||||||
const mailOptions: Mail.Options = {
|
const mailOptions: Mail.Options = {
|
||||||
from: `"Portfolio Contact" <${user}>`,
|
from: `"Portfolio Contact" <${user}>`,
|
||||||
to: "contact@dk0.dev", // Send to your contact email
|
to: "contact@dk0.dev",
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
subject: `Portfolio Kontakt: ${subject}`,
|
subject: `📬 Neue Anfrage: ${subject}`,
|
||||||
html: `
|
html: buildNotificationEmail({ name, email, subject, messageHtml, initial, replyHref, sentAt }),
|
||||||
<!DOCTYPE html>
|
text: `Neue Kontaktanfrage\n\nVon: ${name} (${email})\nBetreff: ${subject}\n\n${message}\n\n---\nEingegangen: ${sentAt}`,
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Neue Kontaktanfrage - Portfolio</title>
|
|
||||||
</head>
|
|
||||||
<body style="margin:0;padding:0;background-color:#fdfcf8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;color:#292524;">
|
|
||||||
<div style="max-width:640px;margin:0 auto;padding:28px 14px;">
|
|
||||||
<div style="background:#ffffff;border:1px solid #e7e5e4;border-radius:20px;overflow:hidden;box-shadow:0 18px 50px rgba(0,0,0,0.08);">
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div style="background:#292524;padding:22px 26px;">
|
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;">
|
|
||||||
<div style="font-weight:700;font-size:16px;letter-spacing:-0.01em;color:#fdfcf8;">
|
|
||||||
Dennis Konkol
|
|
||||||
</div>
|
|
||||||
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,'Liberation Mono','Courier New',monospace;font-weight:700;font-size:14px;color:#fdfcf8;">
|
|
||||||
dk<span style="color:#ef4444;">0</span>.dev
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:10px;">
|
|
||||||
<div style="font-size:22px;font-weight:800;letter-spacing:-0.02em;color:#fdfcf8;">
|
|
||||||
Neue Kontaktanfrage
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:4px;font-size:13px;color:#d6d3d1;">
|
|
||||||
Eingegangen am ${sentAt}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="height:3px;background:#a7f3d0;margin-top:18px;border-radius:999px;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div style="padding:26px;">
|
|
||||||
<!-- Sender -->
|
|
||||||
<div style="display:flex;align-items:flex-start;gap:14px;">
|
|
||||||
<div style="width:44px;height:44px;border-radius:14px;background:#f3f1e7;border:1px solid #e7e5e4;display:flex;align-items:center;justify-content:center;font-weight:800;color:#292524;">
|
|
||||||
${escapeHtml(initial)}
|
|
||||||
</div>
|
|
||||||
<div style="flex:1;min-width:0;">
|
|
||||||
<div style="font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#292524;line-height:1.2;">
|
|
||||||
${safeName}
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:6px;font-size:13px;color:#78716c;line-height:1.4;">
|
|
||||||
<span style="font-weight:700;color:#44403c;">E-Mail:</span> ${safeEmail}<br>
|
|
||||||
<span style="font-weight:700;color:#44403c;">Betreff:</span> ${safeSubject}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message -->
|
|
||||||
<div style="margin-top:18px;background:#fdfcf8;border:1px solid #e7e5e4;border-radius:16px;overflow:hidden;">
|
|
||||||
<div style="padding:14px 16px;background:#f3f1e7;border-bottom:1px solid #e7e5e4;">
|
|
||||||
<div style="font-size:12px;letter-spacing:0.08em;text-transform:uppercase;font-weight:800;color:#57534e;">
|
|
||||||
Nachricht
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px;line-height:1.65;color:#292524;font-size:15px;border-left:4px solid #a7f3d0;">
|
|
||||||
${safeMessageHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<div style="margin-top:22px;text-align:center;">
|
|
||||||
<a href="${escapeHtml(replyHref)}"
|
|
||||||
style="display:inline-block;background:#292524;color:#fdfcf8;text-decoration:none;padding:12px 18px;border-radius:999px;font-weight:800;font-size:14px;">
|
|
||||||
Antworten
|
|
||||||
</a>
|
|
||||||
<div style="margin-top:10px;font-size:12px;color:#78716c;">
|
|
||||||
Oder antworte direkt auf diese E-Mail.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div style="padding:18px 26px;background:#fdfcf8;border-top:1px solid #e7e5e4;">
|
|
||||||
<div style="font-size:12px;color:#78716c;line-height:1.5;">
|
|
||||||
Automatisch generiert von <a href="${brandUrl}" style="color:#292524;text-decoration:underline;">dk0.dev</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`,
|
|
||||||
text: `
|
|
||||||
Neue Kontaktanfrage von deinem Portfolio
|
|
||||||
|
|
||||||
Von: ${name} (${email})
|
|
||||||
Betreff: ${subject}
|
|
||||||
|
|
||||||
Nachricht:
|
|
||||||
${message}
|
|
||||||
|
|
||||||
---
|
|
||||||
Diese E-Mail wurde automatisch von dk0.dev generiert.
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sending email
|
|
||||||
|
|
||||||
// Email sending with retry logic
|
|
||||||
let sendAttempts = 0;
|
let sendAttempts = 0;
|
||||||
const maxSendAttempts = 3;
|
|
||||||
let sendSuccess = false;
|
|
||||||
let result = '';
|
let result = '';
|
||||||
|
|
||||||
while (sendAttempts < maxSendAttempts && !sendSuccess) {
|
while (sendAttempts < 3) {
|
||||||
try {
|
try {
|
||||||
sendAttempts++;
|
sendAttempts++;
|
||||||
// Email send attempt
|
result = await new Promise<string>((resolve, reject) => {
|
||||||
|
transport.sendMail(mailOptions, (err, info) => {
|
||||||
const sendMailPromise = () =>
|
if (!err) resolve(info.response);
|
||||||
new Promise<string>((resolve, reject) => {
|
else {
|
||||||
transport.sendMail(mailOptions, function (err, info) {
|
if (process.env.NODE_ENV === 'development') console.error("Error sending email:", err);
|
||||||
if (!err) {
|
|
||||||
// Email sent successfully
|
|
||||||
resolve(info.response);
|
|
||||||
} else {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error("Error sending email:", err);
|
|
||||||
}
|
|
||||||
reject(err.message);
|
reject(err.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
result = await sendMailPromise();
|
|
||||||
sendSuccess = true;
|
|
||||||
// Email process completed successfully
|
|
||||||
} catch (sendError) {
|
} catch (sendError) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (sendAttempts >= 3) {
|
||||||
console.error(`Email send attempt ${sendAttempts} failed:`, sendError);
|
throw new Error(`Failed to send email after 3 attempts: ${sendError}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendAttempts >= maxSendAttempts) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('All email send attempts failed');
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to send email after ${maxSendAttempts} attempts: ${sendError}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before retry
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save contact to database
|
// Save to DB
|
||||||
try {
|
try {
|
||||||
await prisma.contact.create({
|
await prisma.contact.create({ data: { name, email, subject, message, responded: false } });
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
subject,
|
|
||||||
message,
|
|
||||||
responded: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Contact saved to database
|
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') console.error('Error saving contact to DB:', dbError);
|
||||||
console.error('Error saving contact to database:', dbError);
|
|
||||||
}
|
|
||||||
// Don't fail the email send if DB save fails
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({ message: "E-Mail erfolgreich gesendet", messageId: result });
|
||||||
message: "E-Mail erfolgreich gesendet",
|
|
||||||
messageId: result
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Unexpected error in email API:", err);
|
console.error("❌ Unexpected error in email API:", err);
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
error: "Fehler beim Senden der E-Mail",
|
error: "Fehler beim Senden der E-Mail",
|
||||||
details: err instanceof Error ? err.message : 'Unbekannter Fehler'
|
details: err instanceof Error ? err.message : 'Unbekannter Fehler',
|
||||||
}, { status: 500 });
|
}, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { getHobbies } from '@/lib/directus';
|
|||||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/hobbies
|
* GET /api/hobbies
|
||||||
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
|
|||||||
const hobbies = await getHobbies(locale);
|
const hobbies = await getHobbies(locale);
|
||||||
|
|
||||||
if (hobbies && hobbies.length > 0) {
|
if (hobbies && hobbies.length > 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
hobbies,
|
{ hobbies, source: 'directus' },
|
||||||
source: 'directus'
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return empty (component will use hardcoded fallback)
|
// Fallback: return empty (component will use hardcoded fallback)
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
hobbies: null,
|
{ hobbies: null, source: 'fallback' },
|
||||||
source: 'fallback'
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('Error loading hobbies:', error);
|
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getMessages } from "@/lib/directus";
|
import { getMessages } from "@/lib/directus";
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const locale = searchParams.get("locale") || "en";
|
const locale = searchParams.get("locale") || "en";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = await getMessages(locale);
|
const messages = await getMessages(locale);
|
||||||
return NextResponse.json({ messages });
|
return NextResponse.json(
|
||||||
|
{ messages },
|
||||||
|
{ headers: { "Cache-Control": `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return NextResponse.json({ messages: {} }, { status: 500 });
|
return NextResponse.json({ messages: {} }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getSnippets } from '@/lib/directus';
|
import { getSnippets } from '@/lib/directus';
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@@ -9,9 +11,10 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const snippets = await getSnippets(limit, featured);
|
const snippets = await getSnippets(limit, featured);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
snippets: snippets || []
|
{ snippets: snippets || [] },
|
||||||
});
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
|
);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { getTechStack } from '@/lib/directus';
|
|||||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/tech-stack
|
* GET /api/tech-stack
|
||||||
@@ -28,26 +30,24 @@ export async function GET(request: NextRequest) {
|
|||||||
const techStack = await getTechStack(locale);
|
const techStack = await getTechStack(locale);
|
||||||
|
|
||||||
if (techStack && techStack.length > 0) {
|
if (techStack && techStack.length > 0) {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
techStack,
|
{ techStack, source: 'directus' },
|
||||||
source: 'directus'
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return empty (component will use hardcoded fallback)
|
// Fallback: return empty (component will use hardcoded fallback)
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
techStack: null,
|
{ techStack: null, source: 'fallback' },
|
||||||
source: 'fallback'
|
{ headers: { 'Cache-Control': `public, s-maxage=${CACHE_TTL}, stale-while-revalidate=${CACHE_TTL * 2}` } }
|
||||||
});
|
);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('Error loading tech stack:', error);
|
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 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
|
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import dynamic from "next/dynamic";
|
||||||
import RichTextClient from "./RichTextClient";
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
import CurrentlyReading from "./CurrentlyReading";
|
import CurrentlyReading from "./CurrentlyReading";
|
||||||
import ReadBooks from "./ReadBooks";
|
import ReadBooks from "./ReadBooks";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
@@ -22,29 +22,28 @@ const iconMap: Record<string, LucideIcon> = {
|
|||||||
const About = () => {
|
const About = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("home.about");
|
const t = useTranslations("home.about");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
const [_cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [cmsRes, techRes, hobbiesRes, msgRes, booksRes, snippetsRes] = await Promise.all([
|
const [cmsRes, techRes, hobbiesRes, msgRes, snippetsRes] = await Promise.all([
|
||||||
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
||||||
fetch(`/api/tech-stack?locale=${locale}`),
|
fetch(`/api/tech-stack?locale=${locale}`),
|
||||||
fetch(`/api/hobbies?locale=${locale}`),
|
fetch(`/api/hobbies?locale=${locale}`),
|
||||||
fetch(`/api/messages?locale=${locale}`),
|
fetch(`/api/messages?locale=${locale}`),
|
||||||
fetch(`/api/book-reviews?locale=${locale}`),
|
|
||||||
fetch(`/api/snippets?limit=3&featured=true`)
|
fetch(`/api/snippets?limit=3&featured=true`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const cmsData = await cmsRes.json();
|
const cmsData = await cmsRes.json();
|
||||||
if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent);
|
if (cmsData?.content?.html) setCmsHtml(cmsData.content.html as string);
|
||||||
|
|
||||||
const techData = await techRes.json();
|
const techData = await techRes.json();
|
||||||
if (techData?.techStack) setTechStack(techData.techStack);
|
if (techData?.techStack) setTechStack(techData.techStack);
|
||||||
@@ -57,9 +56,6 @@ const About = () => {
|
|||||||
|
|
||||||
const snippetsData = await snippetsRes.json();
|
const snippetsData = await snippetsRes.json();
|
||||||
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
||||||
|
|
||||||
await booksRes.json();
|
|
||||||
// Books data is available but we don't need to track count anymore
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("About data fetch failed:", error);
|
console.error("About data fetch failed:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -83,14 +79,11 @@ const About = () => {
|
|||||||
|
|
||||||
{/* 1. Large Bio Text */}
|
{/* 1. Large Bio Text */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
<div className="space-y-5 sm:space-y-6 md:space-y-8">
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
||||||
{t("title")}<span className="text-liquid-mint">.</span>
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
<div className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -99,15 +92,15 @@ const About = () => {
|
|||||||
<Skeleton className="h-6 w-[95%]" />
|
<Skeleton className="h-6 w-[95%]" />
|
||||||
<Skeleton className="h-6 w-[90%]" />
|
<Skeleton className="h-6 w-[90%]" />
|
||||||
</div>
|
</div>
|
||||||
) : cmsDoc ? (
|
) : cmsHtml ? (
|
||||||
<RichTextClient doc={cmsDoc} />
|
<RichTextClient html={cmsHtml} />
|
||||||
) : (
|
) : (
|
||||||
<p>{t("p1")} {t("p2")}</p>
|
<p>{t("p1")} {t("p2")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-4 sm:pt-6 md:pt-8">
|
<div className="pt-4 sm:pt-6 md:pt-8">
|
||||||
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
|
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-2xl sm:rounded-3xl border border-stone-100 dark:border-stone-700">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-1 sm:mb-2">{t("funFactTitle")}</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400 mb-1 sm:mb-2">{t("funFactTitle")}</p>
|
||||||
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-sm sm:text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,9 +109,6 @@ const About = () => {
|
|||||||
|
|
||||||
{/* 2. Activity / Status Box */}
|
{/* 2. Activity / Status Box */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
className="md:col-span-4 bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
||||||
>
|
>
|
||||||
@@ -126,16 +116,13 @@ const About = () => {
|
|||||||
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
<h3 className="text-lg sm:text-xl font-black mb-6 sm:mb-8 md:mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
||||||
<Activity size={20} /> Status
|
<Activity size={20} /> Status
|
||||||
</h3>
|
</h3>
|
||||||
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} />
|
<ActivityFeed locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 3. AI Chat Box */}
|
{/* 3. AI Chat Box */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
|
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -150,9 +137,6 @@ const About = () => {
|
|||||||
|
|
||||||
{/* 4. Tech Stack */}
|
{/* 4. Tech Stack */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -171,7 +155,7 @@ const About = () => {
|
|||||||
) : (
|
) : (
|
||||||
techStack.map((cat) => (
|
techStack.map((cat) => (
|
||||||
<div key={cat.id} className="space-y-6">
|
<div key={cat.id} className="space-y-6">
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">{cat.name}</h4>
|
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">{cat.name}</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{cat.items?.map((item: TechStackItem) => (
|
{cat.items?.map((item: TechStackItem) => (
|
||||||
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
|
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
|
||||||
@@ -189,9 +173,6 @@ const About = () => {
|
|||||||
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-4 sm:gap-6 md:gap-8">
|
||||||
{/* Library - Larger Span */}
|
{/* Library - Larger Span */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.4 }}
|
transition={{ delay: 0.4 }}
|
||||||
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[350px] sm:min-h-[400px] md:min-h-[500px]"
|
||||||
>
|
>
|
||||||
@@ -214,9 +195,6 @@ const About = () => {
|
|||||||
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
|
<div className="lg:col-span-5 flex flex-col gap-4 sm:gap-6 md:gap-8">
|
||||||
{/* My Gear (Uses) */}
|
{/* My Gear (Uses) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.5 }}
|
transition={{ delay: 0.5 }}
|
||||||
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
|
className="bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
|
||||||
>
|
>
|
||||||
@@ -247,9 +225,6 @@ const About = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.6 }}
|
transition={{ delay: 0.6 }}
|
||||||
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
|
className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
|
||||||
>
|
>
|
||||||
@@ -267,16 +242,16 @@ const About = () => {
|
|||||||
onClick={() => setSelectedSnippet(s)}
|
onClick={() => setSelectedSnippet(s)}
|
||||||
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
|
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
|
||||||
>
|
>
|
||||||
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
||||||
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
|
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-stone-400 italic">No snippets yet.</p>
|
<p className="text-xs text-stone-500 dark:text-stone-400 italic">No snippets yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
|
<Link href={`/${locale}/snippets`} className="mt-6 group/btn inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||||
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
|
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -285,9 +260,6 @@ const About = () => {
|
|||||||
|
|
||||||
{/* 6. Hobbies */}
|
{/* 6. Hobbies */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.5 }}
|
transition={{ delay: 0.5 }}
|
||||||
className="md:col-span-12"
|
className="md:col-span-12"
|
||||||
>
|
>
|
||||||
@@ -375,7 +347,7 @@ const About = () => {
|
|||||||
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedSnippet(null)}
|
onClick={() => setSelectedSnippet(null)}
|
||||||
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Close Laboratory
|
Close Laboratory
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
|
import { Disc3, Gamepad2, Zap, Quote as QuoteIcon } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
interface CustomActivity {
|
interface CustomActivity {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -22,14 +23,28 @@ const techQuotes = {
|
|||||||
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
|
{ content: "Einfachheit ist die Voraussetzung für Verlässlichkeit.", author: "Edsger W. Dijkstra" },
|
||||||
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
|
{ content: "Wenn Debugging der Prozess des Entfernens von Fehlern ist, dann muss Programmieren der Prozess des Einbauens sein.", author: "Edsger W. Dijkstra" },
|
||||||
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
|
{ content: "Gelöschter Code ist gedebuggter Code.", author: "Jeff Sickel" },
|
||||||
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" }
|
{ content: "Zuerst löse das Problem. Dann schreibe den Code.", author: "John Johnson" },
|
||||||
|
{ content: "Jedes Programm kann um mindestens einen Faktor zwei vereinfacht werden. Jedes Programm hat mindestens einen Bug.", author: "Kernighan's Law" },
|
||||||
|
{ content: "Code lesen ist schwieriger als Code schreiben — deshalb schreibt jeder neu.", author: "Joel Spolsky" },
|
||||||
|
{ content: "Die beste Performance-Optimierung ist der Übergang von nicht-funktionierend zu funktionierend.", author: "J. Osterhout" },
|
||||||
|
{ content: "Mach es funktionierend, dann mach es schön, dann mach es schnell — in dieser Reihenfolge.", author: "Kent Beck" },
|
||||||
|
{ content: "Software ist wie Entropie: Es ist schwer zu fassen, wiegt nichts und gehorcht dem zweiten Hauptsatz der Thermodynamik.", author: "Norman Augustine" },
|
||||||
|
{ content: "Gute Software ist nicht die, die keine Bugs hat — sondern die, deren Bugs keine Rolle spielen.", author: "Bruce Eckel" },
|
||||||
|
{ content: "Der einzige Weg, schnell zu gehen, ist, gut zu gehen.", author: "Robert C. Martin" },
|
||||||
],
|
],
|
||||||
en: [
|
en: [
|
||||||
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
|
{ content: "Computer Science is no more about computers than astronomy is about telescopes.", author: "Edsger W. Dijkstra" },
|
||||||
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
|
{ content: "Simplicity is prerequisite for reliability.", author: "Edsger W. Dijkstra" },
|
||||||
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
|
{ content: "If debugging is the process of removing software bugs, then programming must be the process of putting them in.", author: "Edsger W. Dijkstra" },
|
||||||
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
|
{ content: "Deleted code is debugged code.", author: "Jeff Sickel" },
|
||||||
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" }
|
{ content: "First, solve the problem. Then, write the code.", author: "John Johnson" },
|
||||||
|
{ content: "Any program can be simplified by at least a factor of two. Every program has at least one bug.", author: "Kernighan's Law" },
|
||||||
|
{ content: "It's harder to read code than to write it — that's why everyone rewrites.", author: "Joel Spolsky" },
|
||||||
|
{ content: "The best performance optimization is the transition from a non-working state to a working state.", author: "J. Osterhout" },
|
||||||
|
{ content: "Make it work, make it right, make it fast — in that order.", author: "Kent Beck" },
|
||||||
|
{ content: "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the second law of thermodynamics.", author: "Norman Augustine" },
|
||||||
|
{ content: "Good software isn't software with no bugs — it's software whose bugs don't matter.", author: "Bruce Eckel" },
|
||||||
|
{ content: "The only way to go fast is to go well.", author: "Robert C. Martin" },
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,28 +56,18 @@ function getSafeGamingText(details: string | number | undefined, state: string |
|
|||||||
|
|
||||||
export default function ActivityFeed({
|
export default function ActivityFeed({
|
||||||
onActivityChange,
|
onActivityChange,
|
||||||
idleQuote,
|
|
||||||
locale = 'en'
|
locale = 'en'
|
||||||
}: {
|
}: {
|
||||||
onActivityChange?: (active: boolean) => void;
|
onActivityChange?: (active: boolean) => void;
|
||||||
idleQuote?: string;
|
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}) {
|
}) {
|
||||||
const [data, setData] = useState<StatusData | null>(null);
|
const [data, setData] = useState<StatusData | null>(null);
|
||||||
const [hasActivity, setHasActivity] = useState(false);
|
const [hasActivity, setHasActivity] = useState(false);
|
||||||
const [quoteIndex, setQuoteIndex] = useState(0);
|
const [quoteIndex, setQuoteIndex] = useState(() => Math.floor(Math.random() * (techQuotes[locale as keyof typeof techQuotes] || techQuotes.en).length));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const t = useTranslations("home.about.activity");
|
const t = useTranslations("home.about.activity");
|
||||||
|
|
||||||
const currentQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
|
const allQuotes = techQuotes[locale as keyof typeof techQuotes] || techQuotes.en;
|
||||||
|
|
||||||
// Combine CMS quote with tech quotes if available
|
|
||||||
const allQuotes = React.useMemo(() => {
|
|
||||||
if (idleQuote) {
|
|
||||||
return [{ content: idleQuote, author: "Dennis Konkol" }, ...currentQuotes];
|
|
||||||
}
|
|
||||||
return currentQuotes;
|
|
||||||
}, [idleQuote, currentQuotes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -92,9 +97,13 @@ export default function ActivityFeed({
|
|||||||
fetchData();
|
fetchData();
|
||||||
const statusInterval = setInterval(fetchData, 30000);
|
const statusInterval = setInterval(fetchData, 30000);
|
||||||
|
|
||||||
// Cycle quotes every 10 seconds
|
// Pick a random quote every 10 seconds (never the same one twice in a row)
|
||||||
const quoteInterval = setInterval(() => {
|
const quoteInterval = setInterval(() => {
|
||||||
setQuoteIndex((prev) => (prev + 1) % allQuotes.length);
|
setQuoteIndex((prev) => {
|
||||||
|
let next;
|
||||||
|
do { next = Math.floor(Math.random() * allQuotes.length); } while (next === prev && allQuotes.length > 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -125,7 +134,7 @@ export default function ActivityFeed({
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-700 dark:text-stone-300 italic">
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-tight text-stone-300 italic">
|
||||||
“{allQuotes[quoteIndex].content}”
|
“{allQuotes[quoteIndex].content}”
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
|
<p className="text-xs font-black text-stone-400 dark:text-stone-500 uppercase tracking-widest">
|
||||||
@@ -164,7 +173,7 @@ export default function ActivityFeed({
|
|||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{data.gaming.image && (
|
{data.gaming.image && (
|
||||||
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative">
|
<div className="w-12 h-12 rounded-xl overflow-hidden shrink-0 shadow-lg relative">
|
||||||
<img src={data.gaming.image} alt={data.gaming.name} className="w-full h-full object-cover" />
|
<Image src={data.gaming.image} alt={data.gaming.name} fill className="object-cover" sizes="48px" unoptimized />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex flex-col justify-center">
|
<div className="min-w-0 flex flex-col justify-center">
|
||||||
@@ -207,10 +216,12 @@ export default function ActivityFeed({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 relative z-10">
|
<div className="flex gap-4 relative z-10">
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-500">
|
<div className="w-16 h-16 rounded-lg overflow-hidden shrink-0 shadow-md relative group-hover:shadow-xl transition-shadow duration-500">
|
||||||
<img
|
<Image
|
||||||
src={data.music.albumArt}
|
src={data.music.albumArt}
|
||||||
alt="Album Art"
|
alt="Album Art"
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
fill
|
||||||
|
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||||
|
sizes="64px"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex flex-col justify-center">
|
<div className="min-w-0 flex flex-col justify-center">
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function BentoChat() {
|
|||||||
placeholder="Ask me..."
|
placeholder="Ask me..."
|
||||||
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
|
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleSend} className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
<button onClick={handleSend} aria-label="Send message" className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||||
<Send size={18} />
|
<Send size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import { usePathname } from "next/navigation";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ToastProvider } from "@/components/Toast";
|
import { ToastProvider } from "@/components/Toast";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
import { ConsentProvider } from "./ConsentProvider";
|
||||||
import { ConsentProvider, useConsent } from "./ConsentProvider";
|
|
||||||
import { ThemeProvider } from "./ThemeProvider";
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
|
|
||||||
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
|
const BackgroundBlobs = dynamic(
|
||||||
ssr: false,
|
() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })),
|
||||||
loading: () => null,
|
{ ssr: false, loading: () => null }
|
||||||
});
|
);
|
||||||
|
|
||||||
export default function ClientProviders({
|
export default function ClientProviders({
|
||||||
children,
|
children,
|
||||||
@@ -21,66 +19,19 @@ export default function ClientProviders({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [is404Page, setIs404Page] = useState(false);
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
// Check if we're on a 404 page by looking for the data attribute or pathname
|
|
||||||
const check404 = () => {
|
|
||||||
try {
|
|
||||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
|
||||||
const has404Component = document.querySelector('[data-404-page]');
|
|
||||||
const is404Path = pathname === '/404' || (window.location && (window.location.pathname === '/404' || window.location.pathname.includes('404')));
|
|
||||||
setIs404Page(!!has404Component || is404Path);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - 404 detection is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error checking 404 status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Check immediately and after a short delay
|
|
||||||
try {
|
|
||||||
check404();
|
|
||||||
const timeout = setTimeout(check404, 100);
|
|
||||||
const interval = setInterval(check404, 500);
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
clearInterval(interval);
|
|
||||||
} catch {
|
|
||||||
// Silently fail during cleanup
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// If setup fails, just return empty cleanup
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error setting up 404 check:', error);
|
|
||||||
}
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
// Wrap in multiple error boundaries to isolate failures
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConsentProvider>
|
<ConsentProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
<ThemeProvider>
|
||||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
<GatedProviders mounted={mounted}>
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
|
||||||
<motion.div
|
|
||||||
key={pathname}
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</GatedProviders>
|
</GatedProviders>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ConsentProvider>
|
</ConsentProvider>
|
||||||
@@ -95,21 +46,27 @@ function GatedProviders({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
is404Page: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { consent } = useConsent();
|
// Defer animated background blobs until after LCP
|
||||||
|
const [deferredReady, setDeferredReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
let id: ReturnType<typeof setTimeout> | number;
|
||||||
|
if (typeof requestIdleCallback !== "undefined") {
|
||||||
|
id = requestIdleCallback(() => setDeferredReady(true), { timeout: 5000 });
|
||||||
|
return () => cancelIdleCallback(id as number);
|
||||||
|
} else {
|
||||||
|
id = setTimeout(() => setDeferredReady(true), 200);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
// If consent is not decided yet, treat optional features as off
|
return (
|
||||||
const analyticsEnabled = !!consent?.analytics;
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{mounted && <BackgroundBlobs />}
|
{deferredReady && <BackgroundBlobs />}
|
||||||
<div className="relative z-10">{children}</div>
|
<div className="relative z-10">{children}</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import Hero from './Hero';
|
import dynamic from 'next/dynamic';
|
||||||
import About from './About';
|
|
||||||
import Projects from './Projects';
|
// Lazy-load below-fold components so their JS doesn't block initial paint / LCP.
|
||||||
import Contact from './Contact';
|
// SSR stays on (default) so content is in the initial HTML for SEO.
|
||||||
import Footer from './Footer';
|
const About = dynamic(() => import('./About'));
|
||||||
|
const Projects = dynamic(() => import('./Projects'));
|
||||||
|
const Contact = dynamic(() => import('./Contact'));
|
||||||
|
const Footer = dynamic(() => import('./Footer'));
|
||||||
import type {
|
import type {
|
||||||
HeroTranslations,
|
|
||||||
AboutTranslations,
|
AboutTranslations,
|
||||||
ProjectsTranslations,
|
ProjectsTranslations,
|
||||||
ContactTranslations,
|
ContactTranslations,
|
||||||
@@ -27,23 +29,6 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
|||||||
return locale.startsWith('de') ? 'de' : 'en';
|
return locale.startsWith('de') ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) {
|
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
|
||||||
const baseMessages = messageMap[normalLocale];
|
|
||||||
|
|
||||||
const messages = {
|
|
||||||
home: {
|
|
||||||
hero: baseMessages.home.hero
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
|
||||||
<Hero />
|
|
||||||
</NextIntlClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
|
export function AboutClient({ locale }: { locale: string; translations: AboutTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { motion } from "framer-motion";
|
|||||||
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import dynamic from "next/dynamic";
|
||||||
import RichTextClient from "./RichTextClient";
|
const RichTextClient = dynamic(() => import("./RichTextClient"), { ssr: false });
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
const { showEmailSent, showEmailError } = useToast();
|
const { showEmailSent, showEmailError } = useToast();
|
||||||
@@ -14,7 +14,7 @@ const Contact = () => {
|
|||||||
const t = useTranslations("home.contact");
|
const t = useTranslations("home.contact");
|
||||||
const tForm = useTranslations("home.contact.form");
|
const tForm = useTranslations("home.contact.form");
|
||||||
const tInfo = useTranslations("home.contact.info");
|
const tInfo = useTranslations("home.contact.info");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -24,14 +24,14 @@ const Contact = () => {
|
|||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Only use CMS content if it exists for the active locale.
|
// Only use CMS content if it exists for the active locale.
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
} else {
|
} else {
|
||||||
setCmsDoc(null);
|
setCmsHtml(null);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore; fallback to static
|
// ignore; fallback to static
|
||||||
setCmsDoc(null);
|
setCmsHtml(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
@@ -162,17 +162,14 @@ const Contact = () => {
|
|||||||
|
|
||||||
{/* Header Card */}
|
{/* Header Card */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-4 sm:mb-6 md:mb-8">
|
||||||
{t("title")}<span className="text-liquid-mint">.</span>
|
{t("title")}<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
</h2>
|
</h2>
|
||||||
{cmsDoc ? (
|
{cmsHtml ? (
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
<RichTextClient html={cmsHtml} className="prose prose-stone dark:prose-invert max-w-none text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
{t("subtitle")}
|
{t("subtitle")}
|
||||||
@@ -183,19 +180,16 @@ const Contact = () => {
|
|||||||
|
|
||||||
{/* Info Side (Unified Connect Box) */}
|
{/* Info Side (Unified Connect Box) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
|
className="md:col-span-12 lg:col-span-4 flex flex-col gap-4 sm:gap-6"
|
||||||
>
|
>
|
||||||
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
<div className="bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
|
<div className="flex justify-between items-center mb-6 sm:mb-8 md:mb-12">
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Connect</h4>
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Connect</p>
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Online</span>
|
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-700 dark:text-emerald-400">Online</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -203,7 +197,7 @@ const Contact = () => {
|
|||||||
{/* Email */}
|
{/* Email */}
|
||||||
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Email</span>
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Email</span>
|
||||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
@@ -216,7 +210,7 @@ const Contact = () => {
|
|||||||
{/* GitHub */}
|
{/* GitHub */}
|
||||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Code</span>
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Code</span>
|
||||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
@@ -229,7 +223,7 @@ const Contact = () => {
|
|||||||
{/* LinkedIn */}
|
{/* LinkedIn */}
|
||||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Professional</span>
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-1">Professional</span>
|
||||||
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
<span className="text-lg sm:text-xl md:text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
@@ -240,7 +234,7 @@ const Contact = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
|
<div className="mt-6 sm:mt-8 md:mt-12 pt-4 sm:pt-6 md:pt-8 border-t border-stone-100 dark:border-stone-800 relative z-10">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-2">Location</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 mb-2">Location</p>
|
||||||
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
||||||
<MapPin size={14} className="text-liquid-mint" />
|
<MapPin size={14} className="text-liquid-mint" />
|
||||||
<span className="font-bold">{tInfo("locationValue")}</span>
|
<span className="font-bold">{tInfo("locationValue")}</span>
|
||||||
@@ -251,9 +245,6 @@ const Contact = () => {
|
|||||||
|
|
||||||
{/* Form Side */}
|
{/* Form Side */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-2xl sm:rounded-[2.5rem] md:rounded-[3rem] p-6 sm:p-8 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
>
|
>
|
||||||
@@ -264,7 +255,7 @@ const Contact = () => {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
|
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6 md:space-y-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 md:gap-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
{tForm("labels.name")}
|
{tForm("labels.name")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -281,7 +272,7 @@ const Contact = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
{tForm("labels.email")}
|
{tForm("labels.email")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -299,7 +290,7 @@ const Contact = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
{tForm("labels.subject")}
|
{tForm("labels.subject")}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -316,7 +307,7 @@ const Contact = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400 pl-4">
|
||||||
{tForm("labels.message")}
|
{tForm("labels.message")}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ const Footer = () => {
|
|||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Legal</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Legal</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
||||||
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Social</p>
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-600 dark:text-stone-400">Social</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
|
||||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
|
||||||
@@ -52,9 +52,9 @@ const Footer = () => {
|
|||||||
<div className="md:col-span-4 flex justify-start md:justify-end">
|
<div className="md:col-span-4 flex justify-start md:justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={scrollToTop}
|
onClick={scrollToTop}
|
||||||
className="group flex flex-col items-center gap-4 text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
className="group flex flex-col items-center gap-4 text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-600 dark:text-stone-400 vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||||
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
|
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
|
||||||
<ArrowUp size={20} />
|
<ArrowUp size={20} />
|
||||||
</div>
|
</div>
|
||||||
@@ -64,12 +64,12 @@ const Footer = () => {
|
|||||||
|
|
||||||
{/* Bottom Bar */}
|
{/* Bottom Bar */}
|
||||||
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="mt-10 sm:mt-16 md:mt-20 pt-6 sm:pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
|
<p className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">
|
||||||
Built with Next.js, Directus & Passion.
|
Built with Next.js, Directus & Passion.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
<span className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">Systems Online</span>
|
<span className="text-[10px] font-bold text-stone-600 dark:text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
const Grain = () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none fixed inset-0 z-[9999] h-full w-full overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-[-200%] h-[400%] w-[400%] bg-[url('/images/grain.png')] bg-repeat opacity-[0.03] dark:opacity-[0.05]"
|
|
||||||
style={{ transform: 'translate3d(0, 0, 0)' }}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
x: [0, -50, 20, -10, 40, -20, 0],
|
|
||||||
y: [0, 20, -30, 10, -20, 30, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "linear",
|
|
||||||
}}
|
|
||||||
className="absolute inset-[-200%] h-[400%] w-[400%] bg-[url('/images/grain.png')] bg-repeat opacity-[0.03] dark:opacity-[0.05]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Grain;
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Menu, X } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
@@ -26,11 +25,7 @@ const Header = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
|
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
|
||||||
<motion.nav
|
<nav className="animate-slide-down pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4">
|
||||||
initial={{ y: -100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
className="pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4"
|
|
||||||
>
|
|
||||||
{/* Logo Pill */}
|
{/* Logo Pill */}
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
@@ -72,17 +67,16 @@ const Header = () => {
|
|||||||
{isOpen ? <X size={14} /> : <Menu size={14} />}
|
{isOpen ? <X size={14} /> : <Menu size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Overlay */}
|
{/* Mobile Menu Overlay */}
|
||||||
<AnimatePresence>
|
<div
|
||||||
{isOpen && (
|
className={`fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden transition-all duration-200 ${
|
||||||
<motion.div
|
isOpen
|
||||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
}`}
|
||||||
className="fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
@@ -96,9 +90,7 @@ const Header = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,40 +123,30 @@ 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>
|
||||||
</motion.header>
|
</div>
|
||||||
|
</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 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
|
|
||||||
onClick={() => setIsOpen(false)}
|
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 }}
|
|
||||||
className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto"
|
|
||||||
>
|
>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
@@ -178,7 +162,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
|
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
|
||||||
aria-label="Close menu"
|
aria-label="Close menu"
|
||||||
>
|
>
|
||||||
<X size={24} />
|
<XIcon size={24} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,9 +225,7 @@ export default function HeaderClient({ locale, translations }: HeaderClientProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,140 +1,73 @@
|
|||||||
"use client";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
const Hero = () => {
|
interface HeroProps {
|
||||||
const locale = useLocale();
|
locale: string;
|
||||||
const t = useTranslations("home.hero");
|
}
|
||||||
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
export default async function Hero({ locale: _locale }: HeroProps) {
|
||||||
(async () => {
|
const t = await getTranslations("home.hero");
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/messages?locale=${locale}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
setCmsMessages(data.messages || {});
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})();
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
// Helper to get CMS text or fallback
|
|
||||||
const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
<section className="relative min-h-screen flex flex-col items-center justify-center bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
||||||
{/* Liquid Ambient Background */}
|
{/* Liquid Ambient Background — overflow-hidden here so the blobs are clipped, not the image/badge */}
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
<motion.div
|
<div className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
|
||||||
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }}
|
<div className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
|
||||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
|
|
||||||
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20">
|
<div className="relative z-10 max-w-7xl mx-auto w-full pt-12 sm:pt-16 md:pt-20 pb-10 sm:pb-16">
|
||||||
<div className="flex flex-col lg:flex-row items-center gap-8 sm:gap-10 lg:gap-24">
|
<div className="flex flex-col xl:flex-row items-center gap-8 sm:gap-10 xl:gap-24">
|
||||||
|
|
||||||
{/* Left: Text Content */}
|
{/* Left: Text Content */}
|
||||||
<div className="flex-1 text-center lg:text-left space-y-6 sm:space-y-8 md:space-y-10">
|
<div className="flex-1 text-center xl:text-left space-y-6 sm:space-y-8 md:space-y-10">
|
||||||
<motion.div
|
<div className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm animate-[fadeIn_0.5s_ease-out]">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="inline-flex items-center gap-2 sm:gap-3 px-4 sm:px-5 py-2 sm:py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
|
|
||||||
>
|
|
||||||
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
<span className="w-2 h-2 sm:w-2.5 sm:h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||||
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
|
<span className="font-mono text-[10px] sm:text-[11px] font-black uppercase tracking-[0.2em] sm:tracking-[0.3em] text-stone-500">{t("badge")}</span>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
|
<h1 className="text-[2.75rem] sm:text-6xl md:text-8xl lg:text-[9.5rem] font-black tracking-tighter leading-[0.85] text-stone-900 dark:text-stone-50 uppercase">
|
||||||
<motion.span
|
<span className="block">
|
||||||
initial={{ opacity: 0, x: -50 }}
|
{t("line1")}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
</span>
|
||||||
transition={{ duration: 0.8, delay: 0.1 }}
|
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4">
|
||||||
className="block"
|
{t("line2")}
|
||||||
>
|
</span>
|
||||||
{getLabel("hero.line1", "Building")}
|
|
||||||
</motion.span>
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, x: -50 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-2 sm:pb-4"
|
|
||||||
>
|
|
||||||
{getLabel("hero.line2", "Stuff.")}
|
|
||||||
</motion.span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<motion.p
|
<p className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto xl:mx-0 font-light leading-relaxed tracking-tight">
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 1, delay: 0.4 }}
|
|
||||||
className="text-base sm:text-lg md:text-xl lg:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight"
|
|
||||||
>
|
|
||||||
{t("description")}
|
{t("description")}
|
||||||
</motion.p>
|
</p>
|
||||||
|
|
||||||
<motion.div
|
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center xl:justify-start pt-2 sm:pt-4 animate-[fadeIn_0.6s_ease-out_0.3s_both]">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.6 }}
|
|
||||||
className="flex flex-col sm:flex-row items-center gap-4 sm:gap-8 justify-center lg:justify-start pt-2 sm:pt-4"
|
|
||||||
>
|
|
||||||
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
<a href="#projects" className="group relative px-8 sm:px-12 py-4 sm:py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl sm:rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
||||||
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||||
{t("ctaWork")}
|
{t("ctaWork")}
|
||||||
</a>
|
</a>
|
||||||
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors">
|
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-700 dark:text-stone-300 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||||
{t("ctaContact")}
|
{t("ctaContact")}
|
||||||
</a>
|
</a>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: The Photo */}
|
{/* Right: The Photo */}
|
||||||
<motion.div
|
<div className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 xl:w-[500px] xl:h-[500px] shrink-0 mt-4 sm:mt-8 xl:mt-0 animate-[fadeIn_1s_ease-out]">
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
scale: 1
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
opacity: { duration: 1 },
|
|
||||||
scale: { duration: 1 }
|
|
||||||
}}
|
|
||||||
className="relative w-52 h-52 sm:w-64 sm:h-64 md:w-80 md:h-80 lg:w-[500px] lg:h-[500px] shrink-0 mt-4 sm:mt-8 lg:mt-0"
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[3rem] sm:rounded-[4rem] lg:rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
||||||
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
<div className="relative w-full h-full rounded-[2.5rem] sm:rounded-[3rem] lg:rounded-[4rem] overflow-hidden border-[12px] sm:border-[16px] lg:border-[24px] border-white dark:border-stone-900 shadow-[0_30px_60px_-15px_rgba(0,0,0,0.3)] sm:shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
||||||
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority />
|
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority sizes="(max-width: 640px) 208px, (max-width: 768px) 256px, (max-width: 1280px) 320px, 500px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
<div className="absolute -bottom-4 -left-4 sm:-bottom-6 sm:-left-6 bg-white dark:bg-stone-800 px-5 py-3 sm:px-8 sm:py-4 rounded-xl sm:rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
||||||
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
<span className="font-mono text-xs sm:text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4 animate-bounce">
|
||||||
animate={{ y: [0, 15, 0] }}
|
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
|
||||||
className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4"
|
|
||||||
>
|
|
||||||
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
|
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
|
||||||
</motion.div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default Hero;
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const Projects = () => {
|
|||||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 sm:mb-12 md:mb-16 gap-4 sm:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
|
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-2 sm:mb-4 uppercase">
|
||||||
Selected Work<span className="text-liquid-mint">.</span>
|
Selected Work<span className="text-emerald-600 dark:text-emerald-400">.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
<p className="text-base sm:text-lg md:text-xl text-stone-500 max-w-xl font-light">
|
||||||
Projects that pushed my boundaries.
|
Projects that pushed my boundaries.
|
||||||
@@ -74,13 +74,14 @@ const Projects = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="col-span-2 py-12 text-center text-stone-400 dark:text-stone-600 text-sm">
|
||||||
|
No projects yet.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
projects.map((project) => (
|
projects.map((project) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
className="group relative"
|
className="group relative"
|
||||||
>
|
>
|
||||||
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ const ReadBooks = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (reviews.length === 0) {
|
if (reviews.length === 0) {
|
||||||
return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen
|
return (
|
||||||
|
<div className="flex items-center gap-3 py-4 text-sm text-stone-400 dark:text-stone-500">
|
||||||
|
<BookCheck size={16} className="shrink-0" />
|
||||||
|
<span>{t("empty")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
|
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
|
||||||
|
|||||||
@@ -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 */}
|
|
||||||
<ShaderGradient
|
|
||||||
control="props"
|
|
||||||
type="sphere"
|
|
||||||
animate="on"
|
|
||||||
brightness={1.3}
|
|
||||||
cAzimuthAngle={180}
|
|
||||||
cDistance={3.6}
|
|
||||||
cPolarAngle={90}
|
|
||||||
cameraZoom={1}
|
|
||||||
color1="#b01040"
|
|
||||||
color2="#b04a17"
|
|
||||||
color3="#e167c5"
|
|
||||||
positionX={-2.5}
|
|
||||||
positionY={1.5}
|
|
||||||
positionZ={0}
|
|
||||||
rotationX={0}
|
|
||||||
rotationY={15}
|
|
||||||
rotationZ={50}
|
|
||||||
uAmplitude={6.0}
|
|
||||||
uDensity={0.8}
|
|
||||||
uFrequency={5.5}
|
|
||||||
uSpeed={0.5}
|
|
||||||
uStrength={5.0}
|
|
||||||
/>
|
/>
|
||||||
|
{/* Right-center: pink → crimson (was Sphere 2: posX=2.0, posY=-0.5) */}
|
||||||
{/* Sphere 2 - Rechts mitte */}
|
<div
|
||||||
<ShaderGradient
|
style={{
|
||||||
control="props"
|
position: "absolute",
|
||||||
type="sphere"
|
top: "25%",
|
||||||
animate="on"
|
right: "-10%",
|
||||||
brightness={1.25}
|
width: "50%",
|
||||||
cAzimuthAngle={180}
|
height: "60%",
|
||||||
cDistance={3.6}
|
background:
|
||||||
cPolarAngle={90}
|
"radial-gradient(ellipse at 50% 50%, #e167c5 0%, #b01040 40%, transparent 70%)",
|
||||||
cameraZoom={1}
|
filter: "blur(100px)",
|
||||||
color1="#e167c5"
|
opacity: 0.55,
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
|
{/* Lower-left: orange → pink (was Sphere 3: posX=-0.5, posY=-2.0) */}
|
||||||
{/* Sphere 3 - Unten links */}
|
<div
|
||||||
<ShaderGradient
|
style={{
|
||||||
control="props"
|
position: "absolute",
|
||||||
type="sphere"
|
bottom: "-15%",
|
||||||
animate="on"
|
left: "5%",
|
||||||
brightness={1.2}
|
width: "50%",
|
||||||
cAzimuthAngle={180}
|
height: "60%",
|
||||||
cDistance={3.6}
|
background:
|
||||||
cPolarAngle={90}
|
"radial-gradient(ellipse at 50% 50%, #b04a17 0%, #e167c5 40%, transparent 70%)",
|
||||||
cameraZoom={1}
|
filter: "blur(100px)",
|
||||||
color1="#b04a17"
|
opacity: 0.5,
|
||||||
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;
|
|
||||||
|
|||||||
@@ -1,11 +1,38 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
type Theme = "light" | "dark";
|
||||||
children,
|
|
||||||
...props
|
const ThemeCtx = createContext<{ theme: Theme; setTheme: (t: Theme) => void }>({
|
||||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
theme: "light",
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
setTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("theme") as Theme | null;
|
||||||
|
if (stored === "dark" || stored === "light") {
|
||||||
|
setThemeState(stored);
|
||||||
|
document.documentElement.classList.toggle("dark", stored === "dark");
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTheme = (t: Theme) => {
|
||||||
|
setThemeState(t);
|
||||||
|
try {
|
||||||
|
localStorage.setItem("theme", t);
|
||||||
|
} catch {}
|
||||||
|
document.documentElement.classList.toggle("dark", t === "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ThemeCtx.Provider value={{ theme, setTheme }}>{children}</ThemeCtx.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeCtx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { Moon, Sun } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "./ThemeProvider";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
@@ -18,11 +17,9 @@ export function ThemeToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm"
|
className="p-2 rounded-full bg-stone-100 dark:bg-stone-800 text-stone-800 dark:text-stone-100 hover:bg-stone-200 dark:hover:bg-stone-700 transition-colors border border-stone-200 dark:border-stone-700 shadow-sm hover:scale-105 active:scale-95 transition-transform"
|
||||||
aria-label="Toggle theme"
|
aria-label="Toggle theme"
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? (
|
||||||
@@ -30,6 +27,6 @@ export function ThemeToggle() {
|
|||||||
) : (
|
) : (
|
||||||
<Moon size={18} className="text-stone-600" />
|
<Moon size={18} className="text-stone-600" />
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|
||||||
// Log error details to console
|
|
||||||
console.error("Global Error:", error);
|
console.error("Global Error:", error);
|
||||||
console.error("Error Name:", error.name);
|
}
|
||||||
console.error("Error Message:", error.message);
|
|
||||||
console.error("Error Stack:", error.stack);
|
|
||||||
console.error("Error Digest:", error.digest);
|
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,19 +2,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* Grain Effect */
|
|
||||||
.grain-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0.04;
|
|
||||||
mix-blend-mode: overlay;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Warm Brown & Off-White Palette */
|
/* Warm Brown & Off-White Palette */
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { Metadata } from "next";
|
|||||||
import { Inter, Playfair_Display } from "next/font/google";
|
import { Inter, Playfair_Display } from "next/font/google";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ClientProviders from "./components/ClientProviders";
|
import ClientProviders from "./components/ClientProviders";
|
||||||
|
import ShaderGradientBackground from "./components/ShaderGradientBackground";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getBaseUrl } from "@/lib/seo";
|
import { getBaseUrl } from "@/lib/seo";
|
||||||
import ShaderGradientBackground from "./components/ShaderGradientBackground";
|
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
@@ -32,6 +32,8 @@ export default async function RootLayout({
|
|||||||
<html lang={locale} suppressHydrationWarning>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
|
{/* Prevent flash of unstyled theme — reads localStorage before React hydrates */}
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: `try{var t=localStorage.getItem('theme');if(t==='dark')document.documentElement.classList.add('dark');}catch(e){}` }} />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||||
<div className="grain-overlay" aria-hidden="true" />
|
<div className="grain-overlay" aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
|
import { ArrowLeft, Mail, MapPin, Scale, ShieldCheck, Clock } from 'lucide-react';
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import RichTextClient from "../components/RichTextClient";
|
import RichTextClient from "../components/RichTextClient";
|
||||||
|
|
||||||
export default function LegalNotice() {
|
export default function LegalNotice() {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -23,8 +21,8 @@ export default function LegalNotice() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
@@ -36,11 +34,7 @@ export default function LegalNotice() {
|
|||||||
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||||
|
|
||||||
{/* Editorial Header */}
|
{/* Editorial Header */}
|
||||||
<motion.div
|
<div className="animate-fade-in mb-20">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-20"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
@@ -52,21 +46,16 @@ export default function LegalNotice() {
|
|||||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
Legal<span className="text-liquid-mint">.</span>
|
Legal<span className="text-liquid-mint">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Bento Content Grid */}
|
{/* Bento Content Grid */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
|
||||||
{/* Main Legal Content (Large Box) */}
|
{/* Main Legal Content (Large Box) */}
|
||||||
<motion.div
|
<div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
{cmsHtml ? (
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
||||||
>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<RichTextClient doc={cmsDoc} />
|
<RichTextClient html={cmsHtml} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
@@ -91,7 +80,7 @@ export default function LegalNotice() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar Widgets */}
|
{/* Sidebar Widgets */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { Lock, Loader2 } from 'lucide-react';
|
import { Lock, Loader2 } from 'lucide-react';
|
||||||
import ModernAdminDashboard from '@/components/ModernAdminDashboard';
|
import ModernAdminDashboard from '@/components/ModernAdminDashboard';
|
||||||
|
|
||||||
@@ -259,10 +258,12 @@ const AdminPage = () => {
|
|||||||
// Loading state
|
// Loading state
|
||||||
if (authState.isLoading) {
|
if (authState.isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#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,13 +272,23 @@ const AdminPage = () => {
|
|||||||
// Lockout state
|
// Lockout state
|
||||||
if (authState.isLocked) {
|
if (authState.isLocked) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#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>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-[#3e2723] mb-2">Account Locked</h2>
|
<p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-4">
|
||||||
<p className="text-[#5d4037]">Too many failed attempts. Please try again in 15 minutes.</p>
|
dk<span className="text-red-500">0</span>.dev · admin
|
||||||
|
</p>
|
||||||
|
<h1 className="text-2xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 mb-2">
|
||||||
|
Account Locked
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm leading-relaxed mb-8">
|
||||||
|
Too many failed attempts. Please try again in 15 minutes.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
try {
|
try {
|
||||||
@@ -287,82 +298,98 @@ const AdminPage = () => {
|
|||||||
}
|
}
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}}
|
}}
|
||||||
className="mt-4 px-6 py-2 bg-[#5d4037] text-[#faf8f3] rounded-xl hover:bg-[#3e2723] transition-colors"
|
className="px-8 py-3 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all"
|
||||||
>
|
>
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login form
|
// Login form
|
||||||
if (authState.showLogin || !authState.isAuthenticated) {
|
if (authState.showLogin || !authState.isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-[#faf8f3] z-0">
|
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-stone-50 dark:bg-stone-950 px-6">
|
||||||
|
{/* Liquid ambient blobs */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
|
<div className="absolute top-[5%] left-[5%] w-[50vw] h-[50vw] bg-liquid-mint rounded-full blur-[140px] opacity-20" />
|
||||||
|
<div className="absolute bottom-[5%] right-[5%] w-[40vw] h-[40vw] bg-liquid-purple rounded-full blur-[120px] opacity-15" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<div className="relative z-10 w-full max-w-sm animate-[fadeIn_0.4s_ease-out]">
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
<div className="bg-white dark:bg-stone-900 rounded-[2.5rem] border border-stone-200 dark:border-stone-800 overflow-hidden shadow-2xl">
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
<div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
|
||||||
className="w-full max-w-md p-6"
|
|
||||||
>
|
<div className="p-10">
|
||||||
<div className="bg-[#fffcf5] backdrop-blur-xl rounded-3xl p-8 border border-[#d7ccc8] shadow-2xl relative z-10">
|
|
||||||
<div className="text-center mb-8">
|
<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]">
|
<p className="font-mono text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 mb-5">
|
||||||
<Lock className="w-6 h-6 text-[#5d4037]" />
|
dk<span className="text-red-500">0</span>.dev · admin
|
||||||
|
</p>
|
||||||
|
<div className="w-14 h-14 bg-stone-100 dark:bg-stone-800 rounded-[1.25rem] flex items-center justify-center mx-auto mb-5 border border-stone-200 dark:border-stone-700">
|
||||||
|
<Lock className="w-6 h-6 text-stone-700 dark:text-stone-300" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-[#3e2723] mb-2 tracking-tight">Admin Access</h1>
|
<h1 className="text-3xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 mb-1">
|
||||||
<p className="text-[#5d4037]">Enter your password to continue</p>
|
Admin Access
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm">
|
||||||
|
Enter your password to continue
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-5">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={authState.showPassword ? 'text' : 'password'}
|
type={authState.showPassword ? 'text' : 'password'}
|
||||||
value={authState.password}
|
value={authState.password}
|
||||||
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
|
onChange={(e) => setAuthState(prev => ({ ...prev, password: e.target.value }))}
|
||||||
placeholder="Enter password"
|
placeholder="Password"
|
||||||
className="w-full px-4 py-3.5 bg-white border border-[#d7ccc8] rounded-xl text-[#3e2723] placeholder:text-[#a1887f] focus:outline-none focus:ring-2 focus:ring-[#bcaaa4] focus:border-[#5d4037] transition-all shadow-sm"
|
className="w-full px-5 py-4 bg-stone-50 dark:bg-stone-950/50 border border-stone-200 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:border-transparent transition-all"
|
||||||
disabled={authState.isLoading}
|
disabled={authState.isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
|
onClick={() => setAuthState(prev => ({ ...prev, showPassword: !prev.showPassword }))}
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[#a1887f] hover:text-[#5d4037] p-1"
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-stone-400 hover:text-stone-700 dark:hover:text-stone-200 p-1 transition-colors"
|
||||||
>
|
>
|
||||||
{authState.showPassword ? '👁️' : '👁️🗨️'}
|
{authState.showPassword ? '👁️' : '👁️🗨️'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{authState.error && (
|
{authState.error && (
|
||||||
<motion.p
|
<p className="mt-2 text-red-500 text-sm font-medium flex items-center gap-2">
|
||||||
initial={{ opacity: 0, y: -5 }}
|
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mt-2 text-[#d84315] text-sm font-medium flex items-center"
|
|
||||||
>
|
|
||||||
<span className="w-1.5 h-1.5 bg-[#d84315] rounded-full mr-2" />
|
|
||||||
{authState.error}
|
{authState.error}
|
||||||
</motion.p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={authState.isLoading || !authState.password}
|
disabled={authState.isLoading || !authState.password}
|
||||||
className="w-full bg-[#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"
|
className="w-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 py-4 rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 transition-all flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{authState.isLoading ? (
|
{authState.isLoading ? (
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<>
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<span className="text-[#faf8f3]">Authenticating...</span>
|
Authenticating
|
||||||
</div>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[#faf8f3]">Sign In</span>
|
'Sign In'
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
|
{authState.attempts > 0 && (
|
||||||
|
<p className="text-center text-xs text-stone-400 mt-4">
|
||||||
|
{5 - authState.attempts} attempt{5 - authState.attempts !== 1 ? 's' : ''} remaining
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
|
import { ArrowLeft, Shield, Lock, Eye, Database, Globe } from 'lucide-react';
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
|
||||||
import RichTextClient from "../components/RichTextClient";
|
import RichTextClient from "../components/RichTextClient";
|
||||||
|
|
||||||
export default function PrivacyPolicy() {
|
export default function PrivacyPolicy() {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("common");
|
const t = useTranslations("common");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsHtml, setCmsHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -23,8 +21,8 @@ export default function PrivacyPolicy() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.html && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsHtml(data.content.html as string);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
@@ -36,11 +34,7 @@ export default function PrivacyPolicy() {
|
|||||||
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||||
|
|
||||||
{/* Editorial Header */}
|
{/* Editorial Header */}
|
||||||
<motion.div
|
<div className="animate-fade-in mb-20">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className="mb-20"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
@@ -52,21 +46,16 @@ export default function PrivacyPolicy() {
|
|||||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
Privacy<span className="text-liquid-purple">.</span>
|
Privacy<span className="text-liquid-purple">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Bento Content Grid */}
|
{/* Bento Content Grid */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
|
||||||
{/* Main Privacy Text (Large) */}
|
{/* Main Privacy Text (Large) */}
|
||||||
<motion.div
|
<div className="animate-[fadeIn_0.5s_ease-out_0.1s_both] lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
{cmsHtml ? (
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
|
||||||
>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<RichTextClient doc={cmsDoc} />
|
<RichTextClient html={cmsHtml} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
@@ -89,7 +78,7 @@ export default function PrivacyPolicy() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Info Cards */}
|
{/* Quick Info Cards */}
|
||||||
<div className="lg:col-span-4 space-y-8">
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
/* ghostContent.css */
|
|
||||||
|
|
||||||
.content {
|
|
||||||
font-family: "Arial", sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1,
|
|
||||||
.content h2,
|
|
||||||
.content h3,
|
|
||||||
.content h4,
|
|
||||||
.content h5,
|
|
||||||
.content h6 {
|
|
||||||
color: #222;
|
|
||||||
margin-top: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content ul,
|
|
||||||
.content ol {
|
|
||||||
margin: 1em 0;
|
|
||||||
padding-left: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content a {
|
|
||||||
color: #0070f3;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content blockquote {
|
|
||||||
border-left: 4px solid #ddd;
|
|
||||||
padding-left: 1em;
|
|
||||||
color: #666;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content pre {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 1em;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
border-top-color: #3498db;
|
|
||||||
animation: spin 1s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { useWebVitals } from '@/lib/useWebVitals';
|
|
||||||
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
|
||||||
import { debounce } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface AnalyticsProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
|
||||||
const hasTrackedInitialView = useRef(false);
|
|
||||||
const hasTrackedPerformance = useRef(false);
|
|
||||||
const currentPath = useRef('');
|
|
||||||
|
|
||||||
// Initialize Web Vitals tracking - wrapped to prevent crashes
|
|
||||||
// Hooks must be called unconditionally, but the hook itself handles errors
|
|
||||||
useWebVitals();
|
|
||||||
|
|
||||||
// Track page view - memoized to prevent recreation
|
|
||||||
const trackPageView = useCallback(async () => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const path = window.location.pathname;
|
|
||||||
|
|
||||||
// Only track if path has changed (prevents duplicate tracking)
|
|
||||||
if (currentPath.current === path && hasTrackedInitialView.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPath.current = path;
|
|
||||||
hasTrackedInitialView.current = true;
|
|
||||||
|
|
||||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
||||||
const projectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
|
|
||||||
// Track to Umami (if available)
|
|
||||||
trackEvent('page-view', {
|
|
||||||
url: path,
|
|
||||||
referrer: document.referrer,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track to our API - single call
|
|
||||||
try {
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'pageview',
|
|
||||||
projectId: projectId,
|
|
||||||
page: path
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error tracking page view:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Wrap entire effect in try-catch to prevent any errors from breaking the app
|
|
||||||
try {
|
|
||||||
// Track page load performance - wrapped in try-catch
|
|
||||||
try {
|
|
||||||
trackPageLoad();
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking page load:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track initial page view
|
|
||||||
trackPageView();
|
|
||||||
|
|
||||||
// Track performance metrics to our API - only once
|
|
||||||
const trackPerformanceToAPI = async () => {
|
|
||||||
// Prevent duplicate tracking
|
|
||||||
if (hasTrackedPerformance.current) return;
|
|
||||||
hasTrackedPerformance.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current page path to extract project ID if on project page
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
||||||
const projectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
|
|
||||||
// Wait for page to fully load
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
|
||||||
const paintEntries = performance.getEntriesByType('paint');
|
|
||||||
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
|
|
||||||
|
|
||||||
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
|
|
||||||
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
|
|
||||||
|
|
||||||
const performanceData = {
|
|
||||||
loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
|
|
||||||
fcp: fcp ? fcp.startTime : 0,
|
|
||||||
lcp: lcp ? lcp.startTime : 0,
|
|
||||||
ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
|
|
||||||
cls: 0, // Will be updated by CLS observer
|
|
||||||
fid: 0, // Will be updated by FID observer
|
|
||||||
si: 0 // Speed Index - would need to calculate
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send performance data - single call
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'performance',
|
|
||||||
projectId: projectId,
|
|
||||||
page: path,
|
|
||||||
performance: performanceData
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - performance tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error collecting performance data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2500); // Wait 2.5 seconds for page to stabilize
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error tracking performance:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track performance after page load
|
|
||||||
if (document.readyState === 'complete') {
|
|
||||||
trackPerformanceToAPI();
|
|
||||||
} else {
|
|
||||||
window.addEventListener('load', trackPerformanceToAPI, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track route changes (for SPA navigation) - debounced
|
|
||||||
const handleRouteChange = debounce(() => {
|
|
||||||
// Track new page view (trackPageView will handle path change detection)
|
|
||||||
trackPageView();
|
|
||||||
trackPageLoad();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
// Listen for popstate events (back/forward navigation)
|
|
||||||
window.addEventListener('popstate', handleRouteChange);
|
|
||||||
|
|
||||||
// Track user interactions - debounced to prevent spam
|
|
||||||
const handleClick = debounce((event: unknown) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const mouseEvent = event as MouseEvent;
|
|
||||||
const target = mouseEvent.target as HTMLElement | null;
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
|
|
||||||
const className = target.className;
|
|
||||||
const id = target.id;
|
|
||||||
|
|
||||||
trackEvent('click', {
|
|
||||||
element,
|
|
||||||
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined,
|
|
||||||
id: id || undefined,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - click tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking click:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Track form submissions
|
|
||||||
const handleSubmit = (event: SubmitEvent) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const form = event.target as HTMLFormElement | null;
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
trackEvent('form-submit', {
|
|
||||||
formId: form.id || undefined,
|
|
||||||
formClass: form.className || undefined,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - form tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking form submit:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track scroll depth - debounced
|
|
||||||
let maxScrollDepth = 0;
|
|
||||||
const firedScrollMilestones = new Set<number>();
|
|
||||||
const handleScroll = debounce(() => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const scrollHeight = document.documentElement.scrollHeight;
|
|
||||||
const innerHeight = window.innerHeight;
|
|
||||||
|
|
||||||
if (scrollHeight <= innerHeight) return; // No scrollable content
|
|
||||||
|
|
||||||
const scrollDepth = Math.round(
|
|
||||||
(window.scrollY / (scrollHeight - innerHeight)) * 100
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
|
|
||||||
|
|
||||||
// Track each milestone once (avoid spamming events on every scroll tick)
|
|
||||||
const milestones = [25, 50, 75, 90];
|
|
||||||
for (const milestone of milestones) {
|
|
||||||
if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
|
|
||||||
firedScrollMilestones.add(milestone);
|
|
||||||
trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - scroll tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking scroll:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
document.addEventListener('click', handleClick);
|
|
||||||
document.addEventListener('submit', handleSubmit);
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
|
|
||||||
// Track errors
|
|
||||||
const handleError = (event: ErrorEvent) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('error', {
|
|
||||||
message: event.message || 'Unknown error',
|
|
||||||
filename: event.filename || undefined,
|
|
||||||
lineno: event.lineno || undefined,
|
|
||||||
colno: event.colno || undefined,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - error tracking should not cause more errors
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking error event:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('unhandled-rejection', {
|
|
||||||
reason: event.reason?.toString() || 'Unknown rejection',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - error tracking should not cause more errors
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking unhandled rejection:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('error', handleError);
|
|
||||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
// Cancel any pending debounced calls to prevent memory leaks
|
|
||||||
handleRouteChange.cancel();
|
|
||||||
handleClick.cancel();
|
|
||||||
handleScroll.cancel();
|
|
||||||
|
|
||||||
// Remove event listeners
|
|
||||||
window.removeEventListener('load', trackPerformanceToAPI);
|
|
||||||
window.removeEventListener('popstate', handleRouteChange);
|
|
||||||
document.removeEventListener('click', handleClick);
|
|
||||||
document.removeEventListener('submit', handleSubmit);
|
|
||||||
window.removeEventListener('scroll', handleScroll);
|
|
||||||
window.removeEventListener('error', handleError);
|
|
||||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
|
||||||
} catch {
|
|
||||||
// Silently fail during cleanup
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// If anything fails, log but don't break the app
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('AnalyticsProvider initialization error:', error);
|
|
||||||
}
|
|
||||||
// Return empty cleanup function
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}, [trackPageView]);
|
|
||||||
|
|
||||||
// Always render children, even if analytics fails
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
@@ -1,169 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useMotionValue, useTransform, useSpring } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const BackgroundBlobs = () => {
|
const BackgroundBlobs = () => {
|
||||||
const mouseX = useMotionValue(0);
|
|
||||||
const mouseY = useMotionValue(0);
|
|
||||||
|
|
||||||
const springConfig = { damping: 50, stiffness: 50, mass: 2 };
|
|
||||||
const springX = useSpring(mouseX, springConfig);
|
|
||||||
const springY = useSpring(mouseY, springConfig);
|
|
||||||
|
|
||||||
// Very subtle parallax offsets
|
|
||||||
const x1 = useTransform(springX, (value) => value / 30);
|
|
||||||
const y1 = useTransform(springY, (value) => value / 30);
|
|
||||||
|
|
||||||
const x2 = useTransform(springX, (value) => value / -25);
|
|
||||||
const y2 = useTransform(springY, (value) => value / -25);
|
|
||||||
|
|
||||||
const x3 = useTransform(springX, (value) => value / 20);
|
|
||||||
const y3 = useTransform(springY, (value) => value / 20);
|
|
||||||
|
|
||||||
const x4 = useTransform(springX, (value) => value / -35);
|
|
||||||
const y4 = useTransform(springY, (value) => value / -35);
|
|
||||||
|
|
||||||
const x5 = useTransform(springX, (value) => value / 15);
|
|
||||||
const y5 = useTransform(springY, (value) => value / 15);
|
|
||||||
|
|
||||||
// Prevent hydration mismatch
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
|
||||||
const x = e.clientX - window.innerWidth / 2;
|
|
||||||
const y = e.clientY - window.innerHeight / 2;
|
|
||||||
mouseX.set(x);
|
|
||||||
mouseY.set(y);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
|
||||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
}, [mouseX, mouseY, mounted]);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0">
|
||||||
{/* Mint blob - top left */}
|
{/* Mint blob - top left */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[100px] mix-blend-multiply"
|
className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-liquid-mint/40 rounded-full blur-[60px] opacity-70"
|
||||||
style={{ x: x1, y: y1 }}
|
animate={{ scale: [1, 1.15, 1] }}
|
||||||
animate={{
|
transition={{ duration: 40, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||||
scale: [1, 1.15, 1],
|
|
||||||
rotate: [0, 45, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 40,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Lavender blob - top right */}
|
{/* Lavender blob - top right */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[100px] mix-blend-multiply"
|
className="absolute top-[10%] right-[-5%] w-[35vw] h-[35vw] bg-liquid-lavender/35 rounded-full blur-[60px] opacity-70"
|
||||||
style={{ x: x2, y: y2 }}
|
animate={{ scale: [1, 1.1, 1] }}
|
||||||
animate={{
|
transition={{ duration: 45, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||||
scale: [1, 1.1, 1],
|
|
||||||
rotate: [0, -30, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 45,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rose blob - bottom left */}
|
{/* Rose blob - bottom left */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[100px] mix-blend-multiply"
|
className="absolute bottom-[-5%] left-[15%] w-[45vw] h-[45vw] bg-liquid-rose/35 rounded-full blur-[60px] opacity-70"
|
||||||
style={{ x: x3, y: y3 }}
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
animate={{
|
transition={{ duration: 50, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||||
scale: [1, 1.2, 1],
|
|
||||||
rotate: [0, 60, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 50,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Peach blob - middle right */}
|
{/* Peach blob - middle right */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[120px] mix-blend-multiply"
|
className="absolute top-[40%] right-[10%] w-[30vw] h-[30vw] bg-orange-200/30 rounded-full blur-[60px] opacity-70"
|
||||||
style={{ x: x4, y: y4 }}
|
animate={{ scale: [1, 1.25, 1] }}
|
||||||
animate={{
|
transition={{ duration: 55, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||||
scale: [1, 1.25, 1],
|
|
||||||
rotate: [0, -45, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 55,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Blue blob - center */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-[50%] left-[40%] w-[38vw] h-[38vw] bg-blue-200/30 rounded-full blur-[110px] mix-blend-multiply"
|
|
||||||
style={{ x: x5, y: y5 }}
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.18, 1],
|
|
||||||
rotate: [0, 90, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 48,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pink blob - bottom right */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-[10%] right-[-8%] w-[32vw] h-[32vw] bg-pink-200/35 rounded-full blur-[100px] mix-blend-multiply"
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.12, 1],
|
|
||||||
rotate: [0, -60, 0],
|
|
||||||
x: [0, -20, 0],
|
|
||||||
y: [0, 20, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 43,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Yellow-green blob - top center */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute top-[5%] left-[45%] w-[28vw] h-[28vw] bg-lime-200/30 rounded-full blur-[115px] mix-blend-multiply"
|
|
||||||
animate={{
|
|
||||||
scale: [1, 1.22, 1],
|
|
||||||
rotate: [0, 75, 0],
|
|
||||||
x: [0, 15, 0],
|
|
||||||
y: [0, -15, 0],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 52,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -21,23 +20,23 @@ import dynamic from 'next/dynamic';
|
|||||||
|
|
||||||
const EmailManager = dynamic(
|
const EmailManager = dynamic(
|
||||||
() => import('./EmailManager').then((m) => m.EmailManager),
|
() => import('./EmailManager').then((m) => m.EmailManager),
|
||||||
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading emails…</div> }
|
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading emails…</div> }
|
||||||
);
|
);
|
||||||
const AnalyticsDashboard = dynamic(
|
const AnalyticsDashboard = dynamic(
|
||||||
() => import('./AnalyticsDashboard').then((m) => m.default),
|
() => import('./AnalyticsDashboard').then((m) => m.default),
|
||||||
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading analytics…</div> }
|
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading analytics…</div> }
|
||||||
);
|
);
|
||||||
const ImportExport = dynamic(
|
const ImportExport = dynamic(
|
||||||
() => import('./ImportExport').then((m) => m.default),
|
() => import('./ImportExport').then((m) => m.default),
|
||||||
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading tools…</div> }
|
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading tools…</div> }
|
||||||
);
|
);
|
||||||
const ProjectManager = dynamic(
|
const ProjectManager = dynamic(
|
||||||
() => import('./ProjectManager').then((m) => m.ProjectManager),
|
() => import('./ProjectManager').then((m) => m.ProjectManager),
|
||||||
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading projects…</div> }
|
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading projects…</div> }
|
||||||
);
|
);
|
||||||
const ContentManager = dynamic(
|
const ContentManager = dynamic(
|
||||||
() => import('./ContentManager').then((m) => m.default),
|
() => import('./ContentManager').then((m) => m.default),
|
||||||
{ ssr: false, loading: () => <div className="p-6 text-stone-500">Loading content…</div> }
|
{ ssr: false, loading: () => <div className="p-6 text-stone-400">Loading content…</div> }
|
||||||
);
|
);
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -69,8 +68,10 @@ interface ModernAdminDashboardProps {
|
|||||||
isAuthenticated?: boolean;
|
isAuthenticated?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabId = 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings';
|
||||||
|
|
||||||
const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthenticated = true }) => {
|
const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthenticated = true }) => {
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings'>('overview');
|
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -180,7 +181,6 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
|||||||
totalViews: ((analytics?.overview as Record<string, unknown>)?.totalViews as number) || (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0),
|
totalViews: ((analytics?.overview as Record<string, unknown>)?.totalViews as number) || (analytics?.totalViews as number) || projects.reduce((sum, p) => sum + (p.analytics?.views || 0), 0),
|
||||||
unreadEmails: emails.filter(e => !(e.read as boolean)).length,
|
unreadEmails: emails.filter(e => !(e.read as boolean)).length,
|
||||||
avgPerformance: (() => {
|
avgPerformance: (() => {
|
||||||
// Only show real performance data, not defaults
|
|
||||||
const projectsWithPerf = projects.filter(p => {
|
const projectsWithPerf = projects.filter(p => {
|
||||||
const perf = p.performance as Record<string, unknown> || {};
|
const perf = p.performance as Record<string, unknown> || {};
|
||||||
return (perf.lighthouse as number || 0) > 0;
|
return (perf.lighthouse as number || 0) > 0;
|
||||||
@@ -198,7 +198,6 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prioritize the data needed for the initial dashboard render
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await Promise.all([loadProjects(), loadSystemStats()]);
|
await Promise.all([loadProjects(), loadSystemStats()]);
|
||||||
|
|
||||||
@@ -218,63 +217,136 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
|||||||
}, [loadProjects, loadSystemStats, loadAnalytics, loadEmails]);
|
}, [loadProjects, loadSystemStats, loadAnalytics, loadEmails]);
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ id: 'overview', label: 'Dashboard', icon: Home, color: 'blue', description: 'Overview & Statistics' },
|
{ id: 'overview' as TabId, label: 'Dashboard', icon: Home, description: 'Overview & Statistics' },
|
||||||
{ id: 'projects', label: 'Projects', icon: Database, color: 'green', description: 'Manage Projects' },
|
{ id: 'projects' as TabId, label: 'Projects', icon: Database, description: 'Manage Projects' },
|
||||||
{ id: 'emails', label: 'Emails', icon: Mail, color: 'purple', description: 'Email Management' },
|
{ id: 'emails' as TabId, label: 'Emails', icon: Mail, description: 'Email Management' },
|
||||||
{ id: 'analytics', label: 'Analytics', icon: Activity, color: 'orange', description: 'Site Analytics' },
|
{ id: 'analytics' as TabId, label: 'Analytics', icon: Activity, description: 'Site Analytics' },
|
||||||
{ id: 'content', label: 'Content', icon: Shield, color: 'teal', description: 'Texts, pages & localization' },
|
{ id: 'content' as TabId, label: 'Content', icon: Shield, description: 'Texts, pages & localization' },
|
||||||
{ id: 'settings', label: 'Settings', icon: Settings, color: 'gray', description: 'System Settings' }
|
{ id: 'settings' as TabId, label: 'Settings', icon: Settings, description: 'System Settings' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: 'Projects',
|
||||||
|
value: stats.totalProjects,
|
||||||
|
sub: `${stats.publishedProjects} published`,
|
||||||
|
icon: Database,
|
||||||
|
tab: 'projects' as TabId,
|
||||||
|
gradient: 'from-emerald-400/20 to-emerald-400/5',
|
||||||
|
border: 'border-emerald-400/20 dark:border-emerald-400/10',
|
||||||
|
iconColor: 'text-emerald-500',
|
||||||
|
tooltip: 'REAL DATA: Total projects in your portfolio from the database. Shows published vs unpublished count.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Page Views',
|
||||||
|
value: stats.totalViews.toLocaleString(),
|
||||||
|
sub: `${stats.totalUsers} users`,
|
||||||
|
icon: Activity,
|
||||||
|
tab: 'analytics' as TabId,
|
||||||
|
gradient: 'from-sky-400/20 to-sky-400/5',
|
||||||
|
border: 'border-sky-400/20 dark:border-sky-400/10',
|
||||||
|
iconColor: 'text-sky-500',
|
||||||
|
tooltip: 'REAL DATA: Total page views from PageView table (last 30 days). Users = unique IP addresses.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Messages',
|
||||||
|
value: emails.length,
|
||||||
|
sub: stats.unreadEmails > 0 ? `${stats.unreadEmails} unread` : 'all read',
|
||||||
|
subColor: stats.unreadEmails > 0 ? 'text-red-500' : 'text-emerald-500',
|
||||||
|
icon: Mail,
|
||||||
|
tab: 'emails' as TabId,
|
||||||
|
gradient: 'from-purple-400/20 to-purple-400/5',
|
||||||
|
border: 'border-purple-400/20 dark:border-purple-400/10',
|
||||||
|
iconColor: 'text-purple-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Performance',
|
||||||
|
value: stats.avgPerformance || 'N/A',
|
||||||
|
sub: 'Lighthouse score',
|
||||||
|
icon: TrendingUp,
|
||||||
|
tab: 'analytics' as TabId,
|
||||||
|
gradient: 'from-amber-400/20 to-amber-400/5',
|
||||||
|
border: 'border-amber-400/20 dark:border-amber-400/10',
|
||||||
|
iconColor: 'text-amber-500',
|
||||||
|
tooltip: stats.avgPerformance > 0
|
||||||
|
? 'REAL DATA: Average Lighthouse score from real Web Vitals collected from page visits.'
|
||||||
|
: 'No performance data yet. Scores appear after visitors load pages.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Bounce Rate',
|
||||||
|
value: `${stats.bounceRate}%`,
|
||||||
|
sub: 'Exit rate',
|
||||||
|
icon: Users,
|
||||||
|
tab: 'analytics' as TabId,
|
||||||
|
gradient: 'from-pink-400/20 to-pink-400/5',
|
||||||
|
border: 'border-pink-400/20 dark:border-pink-400/10',
|
||||||
|
iconColor: 'text-pink-500',
|
||||||
|
tooltip: 'REAL DATA: Percentage of sessions with only 1 pageview. Lower is better.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'System',
|
||||||
|
value: 'Online',
|
||||||
|
sub: 'Operational',
|
||||||
|
icon: Shield,
|
||||||
|
tab: 'settings' as TabId,
|
||||||
|
gradient: 'from-teal-400/20 to-teal-400/5',
|
||||||
|
border: 'border-teal-400/20 dark:border-teal-400/10',
|
||||||
|
iconColor: 'text-teal-500',
|
||||||
|
pulse: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen bg-stone-50 dark:bg-stone-950">
|
||||||
{/* Animated Background - same as main site */}
|
|
||||||
<div className="fixed inset-0 animated-bg"></div>
|
{/* Navbar */}
|
||||||
|
<div className="sticky top-0 z-50 bg-stone-50/90 dark:bg-stone-950/90 backdrop-blur-xl border-b border-stone-200 dark:border-stone-800">
|
||||||
|
{/* Gradient accent bar */}
|
||||||
|
<div className="h-0.5 bg-gradient-to-r from-emerald-400 via-sky-400 to-purple-400" />
|
||||||
|
|
||||||
{/* Admin Navbar - Horizontal Navigation */}
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="admin-glass border-b border-white/20 sticky top-0">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-14">
|
||||||
{/* Left side - Logo and Admin Panel */}
|
|
||||||
<div className="flex items-center space-x-4">
|
{/* Left: branding */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center space-x-2 text-stone-900 hover:text-black transition-colors"
|
className="flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-stone-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Home size={20} className="text-stone-600" />
|
<Home size={16} />
|
||||||
<span className="font-medium text-stone-900">Portfolio</span>
|
<span className="font-mono text-xs font-black tracking-tighter">
|
||||||
|
dk<span className="text-red-500">0</span>.dev
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="h-6 w-px bg-stone-300" />
|
<div className="h-4 w-px bg-stone-300 dark:bg-stone-700" />
|
||||||
<div className="flex items-center space-x-2">
|
<span className="font-black text-xs uppercase tracking-[0.15em] text-stone-900 dark:text-stone-50">
|
||||||
<Shield size={20} className="text-stone-600" />
|
Admin
|
||||||
<span className="text-stone-900 font-semibold">Admin Panel</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - Desktop Navigation */}
|
{/* Center: desktop tabs */}
|
||||||
<div className="hidden md:flex items-center space-x-1">
|
<div className="hidden md:flex items-center gap-1">
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings')}
|
onClick={() => setActiveTab(item.id)}
|
||||||
className={`flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-[0.1em] transition-all duration-200 ${
|
||||||
activeTab === item.id
|
activeTab === item.id
|
||||||
? 'bg-stone-100 text-stone-900 font-medium shadow-sm border border-stone-200'
|
? 'bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900'
|
||||||
: 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
|
: 'text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-50 hover:bg-stone-100 dark:hover:bg-stone-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={16} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} />
|
<item.icon size={13} />
|
||||||
<span className="text-sm">{item.label}</span>
|
{item.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - User info and Logout */}
|
{/* Right: user + logout + mobile toggle */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="hidden sm:block text-sm text-stone-500">
|
<span className="hidden sm:block text-xs text-stone-400 dark:text-stone-500 font-mono">
|
||||||
Welcome, <span className="text-stone-800 font-semibold">Dennis</span>
|
Dennis
|
||||||
</div>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -284,321 +356,207 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
|||||||
window.location.href = '/manage';
|
window.location.href = '/manage';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
console.error('Logout failed:', error);
|
||||||
// Force logout anyway
|
|
||||||
sessionStorage.removeItem('admin_authenticated');
|
sessionStorage.removeItem('admin_authenticated');
|
||||||
sessionStorage.removeItem('admin_session_token');
|
sessionStorage.removeItem('admin_session_token');
|
||||||
window.location.href = '/manage';
|
window.location.href = '/manage';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-red-50 text-stone-500 hover:text-red-600 transition-all duration-200 border border-transparent hover:border-red-100"
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold uppercase tracking-[0.1em] text-stone-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
<LogOut size={13} />
|
||||||
<span className="hidden sm:inline text-sm font-medium">Logout</span>
|
<span className="hidden sm:inline">Logout</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
className="md:hidden flex items-center justify-center p-2 rounded-lg text-stone-600 hover:bg-stone-100 transition-colors"
|
className="md:hidden p-2 rounded-xl text-stone-600 dark:text-stone-400 hover:bg-stone-100 dark:hover:bg-stone-900 transition-colors"
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
{mobileMenuOpen ? <X size={18} /> : <Menu size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation Menu */}
|
{/* Mobile menu */}
|
||||||
<AnimatePresence>
|
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<motion.div
|
<div className="md:hidden border-t border-stone-200 dark:border-stone-800 bg-stone-50 dark:bg-stone-950">
|
||||||
initial={{ opacity: 0, height: 0 }}
|
<div className="px-4 py-3 grid grid-cols-2 gap-2">
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, height: 0 }}
|
|
||||||
className="md:hidden border-t border-stone-200 bg-white"
|
|
||||||
>
|
|
||||||
<div className="px-4 py-4 space-y-2">
|
|
||||||
{navigation.map((item) => (
|
{navigation.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveTab(item.id as 'overview' | 'projects' | 'emails' | 'analytics' | 'content' | 'settings');
|
setActiveTab(item.id);
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200 ${
|
className={`flex items-center gap-2 px-4 py-3 rounded-2xl transition-all duration-200 text-left ${
|
||||||
activeTab === item.id
|
activeTab === item.id
|
||||||
? 'bg-stone-100 text-stone-900 shadow-sm border border-stone-200'
|
? 'bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900'
|
||||||
: 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
|
: 'text-stone-500 dark:text-stone-400 hover:text-stone-900 dark:hover:text-stone-50 bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon size={18} className={activeTab === item.id ? 'text-stone-800' : 'text-stone-400'} />
|
<item.icon size={16} />
|
||||||
<div className="text-left">
|
<div>
|
||||||
<div className="font-medium text-sm">{item.label}</div>
|
<div className="font-bold text-xs uppercase tracking-[0.1em]">{item.label}</div>
|
||||||
<div className="text-xs opacity-70">{item.description}</div>
|
<div className="text-[10px] opacity-60">{item.description}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content - Full Width Horizontal Layout */}
|
{/* Main content */}
|
||||||
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-6 lg:py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8">
|
||||||
{/* Content */}
|
|
||||||
<AnimatePresence mode="wait">
|
{/* Overview tab */}
|
||||||
<motion.div
|
|
||||||
key={activeTab}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -20 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
>
|
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-stone-900">Admin Dashboard</h1>
|
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
||||||
<p className="text-stone-500 text-lg">Manage your portfolio and monitor performance</p>
|
Dashboard<span className="text-emerald-500">.</span>
|
||||||
</div>
|
</h1>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">
|
||||||
|
Manage your portfolio and monitor performance
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid - Mobile: 2x3, Desktop: 6x1 horizontal */}
|
{/* Stats grid */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 md:gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
{statCards.map((card) => (
|
||||||
<div
|
<div
|
||||||
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
|
key={card.label}
|
||||||
onClick={() => setActiveTab('projects')}
|
className={`relative group bg-gradient-to-br ${card.gradient} border ${card.border} rounded-3xl p-5 cursor-pointer hover:scale-[1.02] active:scale-[0.98] transition-all duration-200`}
|
||||||
|
onClick={() => setActiveTab(card.tab)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-stone-500 dark:text-stone-400">
|
||||||
<p className="text-stone-500 text-xs md:text-sm font-medium">Projects</p>
|
{card.label}
|
||||||
<Database size={20} className="text-stone-400" />
|
</p>
|
||||||
|
<card.icon size={15} className={card.iconColor} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.totalProjects}</p>
|
<p className="text-2xl font-black tracking-tighter text-stone-900 dark:text-stone-50">
|
||||||
<p className="text-stone-600 text-xs font-medium">{stats.publishedProjects} published</p>
|
{card.pulse ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse" />
|
||||||
|
{card.value}
|
||||||
|
</span>
|
||||||
|
) : card.value}
|
||||||
|
</p>
|
||||||
|
<p className={`text-[11px] font-medium mt-1 ${card.subColor ?? 'text-stone-500 dark:text-stone-400'}`}>
|
||||||
|
{card.sub}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{card.tooltip && (
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 dark:bg-stone-800 text-stone-50 text-[10px] font-medium rounded-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-[200px] z-50 shadow-xl pointer-events-none text-center">
|
||||||
|
{card.tooltip}
|
||||||
|
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 dark:bg-stone-800 rotate-45" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
|
)}
|
||||||
✅ REAL DATA: Total projects in your portfolio from the database. Shows published vs unpublished count.
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Recent Activity + Quick Actions */}
|
||||||
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
onClick={() => setActiveTab('analytics')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-stone-500 text-xs md:text-sm font-medium">Page Views</p>
|
|
||||||
<Activity size={20} className="text-stone-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.totalViews.toLocaleString()}</p>
|
|
||||||
<p className="text-stone-600 text-xs font-medium">{stats.totalUsers} users</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
|
|
||||||
✅ REAL DATA: Total page views from PageView table (last 30 days). Each visit is tracked with IP, user agent, and timestamp. Users = unique IP addresses.
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none"
|
|
||||||
onClick={() => setActiveTab('emails')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-stone-500 text-xs md:text-sm font-medium">Messages</p>
|
|
||||||
<Mail size={20} className="text-stone-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-stone-900">{emails.length}</p>
|
|
||||||
<p className="text-red-500 text-xs font-medium">{stats.unreadEmails} unread</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
|
|
||||||
onClick={() => setActiveTab('analytics')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-stone-500 text-xs md:text-sm font-medium">Performance</p>
|
|
||||||
<TrendingUp size={20} className="text-stone-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.avgPerformance || 'N/A'}</p>
|
|
||||||
<p className="text-stone-600 text-xs font-medium">Lighthouse Score</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
|
|
||||||
{stats.avgPerformance > 0
|
|
||||||
? "✅ REAL DATA: Average Lighthouse score (0-100) calculated from real Web Vitals (LCP, FCP, CLS, FID, TTFB) collected from actual page visits. Only averages projects with real performance data."
|
|
||||||
: "No performance data yet. Scores appear after visitors load pages and Web Vitals are tracked."}
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none group relative"
|
|
||||||
onClick={() => setActiveTab('analytics')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-stone-500 text-xs md:text-sm font-medium">Bounce Rate</p>
|
|
||||||
<Users size={20} className="text-stone-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-stone-900">{stats.bounceRate}%</p>
|
|
||||||
<p className="text-stone-600 text-xs font-medium">Exit rate</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
|
|
||||||
✅ REAL DATA: Percentage of sessions with only 1 pageview (calculated from PageView records grouped by IP). Lower is better. Shows how many visitors leave after viewing just one page.
|
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="admin-glass-light p-4 rounded-xl cursor-pointer transition-all duration-200 transform-none hover:transform-none"
|
|
||||||
onClick={() => setActiveTab('settings')}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-stone-500 text-xs md:text-sm font-medium">System</p>
|
|
||||||
<Shield size={20} className="text-stone-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xl md:text-2xl font-bold text-stone-900">Online</p>
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
|
||||||
<p className="text-stone-600 text-xs font-medium">Operational</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Activity & Quick Actions - Mobile: vertical, Desktop: horizontal */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<div className="admin-glass-card p-6 rounded-xl md:col-span-2">
|
<div className="lg:col-span-2 bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-bold text-stone-900">Recent Activity</h2>
|
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50">
|
||||||
|
Recent Activity
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => loadAllData()}
|
onClick={() => loadAllData()}
|
||||||
className="text-stone-500 hover:text-stone-800 text-sm font-medium px-3 py-1 bg-stone-100 rounded-lg transition-colors border border-stone-200"
|
className="text-xs font-bold uppercase tracking-[0.1em] text-stone-400 hover:text-stone-900 dark:hover:text-stone-50 px-3 py-1.5 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-200 dark:border-stone-700 transition-all"
|
||||||
>
|
>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile: vertical stack, Desktop: horizontal columns */}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-3">
|
||||||
<div className="space-y-6">
|
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Projects</h3>
|
||||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-wider">Projects</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{projects.slice(0, 3).map((project) => (
|
{projects.slice(0, 3).map((project) => (
|
||||||
<div key={project.id} className="flex items-start space-x-3 p-4 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('projects')}>
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="flex items-start gap-3 p-3 bg-stone-50 dark:bg-stone-800/50 border border-stone-100 dark:border-stone-700 rounded-2xl hover:border-stone-300 dark:hover:border-stone-600 transition-all cursor-pointer"
|
||||||
|
onClick={() => setActiveTab('projects')}
|
||||||
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-stone-800 font-medium text-sm truncate">{project.title}</p>
|
<p className="text-stone-900 dark:text-stone-50 font-bold text-sm truncate">{project.title}</p>
|
||||||
<p className="text-stone-500 text-xs">{project.published ? 'Published' : 'Draft'} • {project.analytics?.views || 0} views</p>
|
<p className="text-stone-400 text-xs mt-0.5">{project.analytics?.views || 0} views</p>
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${project.published ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${project.published ? 'bg-emerald-100 dark:bg-emerald-950/30 text-emerald-700 dark:text-emerald-400' : 'bg-amber-100 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400'}`}>
|
||||||
{project.published ? 'Live' : 'Draft'}
|
{project.published ? 'Live' : 'Draft'}
|
||||||
</span>
|
</span>
|
||||||
{project.featured && (
|
{project.featured && (
|
||||||
<span className="px-2 py-1 bg-stone-200 text-stone-700 rounded-full text-xs font-medium">Featured</span>
|
<span className="px-2 py-0.5 bg-stone-100 dark:bg-stone-700 text-stone-600 dark:text-stone-300 rounded-full text-[10px] font-bold">Featured</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
{projects.length === 0 && (
|
||||||
|
<p className="text-stone-400 text-xs py-4">No projects yet</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-wider">Messages</h3>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Messages</h3>
|
||||||
{emails.slice(0, 3).map((email, index) => (
|
{emails.slice(0, 3).map((email, index) => (
|
||||||
<div key={index} className="flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm transition-all duration-200 cursor-pointer" onClick={() => setActiveTab('emails')}>
|
<div
|
||||||
<div className="w-8 h-8 bg-stone-200 rounded-lg flex items-center justify-center flex-shrink-0">
|
key={index}
|
||||||
<Mail size={14} className="text-stone-600" />
|
className="flex items-center gap-3 p-3 bg-stone-50 dark:bg-stone-800/50 border border-stone-100 dark:border-stone-700 rounded-2xl hover:border-stone-300 dark:hover:border-stone-600 transition-all cursor-pointer"
|
||||||
|
onClick={() => setActiveTab('emails')}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 bg-gradient-to-br from-purple-400/20 to-purple-400/5 border border-purple-400/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||||
|
<Mail size={13} className="text-purple-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-stone-800 font-medium text-sm truncate">From {email.name as string}</p>
|
<p className="text-stone-900 dark:text-stone-50 font-bold text-sm truncate">
|
||||||
<p className="text-stone-500 text-xs truncate">{(email.subject as string) || 'No subject'}</p>
|
{email.name as string}
|
||||||
|
</p>
|
||||||
|
<p className="text-stone-400 text-xs truncate">{(email.subject as string) || 'No subject'}</p>
|
||||||
</div>
|
</div>
|
||||||
{!(email.read as boolean) && (
|
{!(email.read as boolean) && (
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full flex-shrink-0"></div>
|
<div className="w-2 h-2 bg-red-500 rounded-full flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
{emails.length === 0 && (
|
||||||
|
<p className="text-stone-400 text-xs py-4">No messages yet</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="admin-glass-card p-6 rounded-xl">
|
<div className="bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
|
||||||
<h2 className="text-xl font-bold text-stone-900 mb-6">Quick Actions</h2>
|
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50 mb-6">
|
||||||
<div className="space-y-4">
|
Quick Actions
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ label: 'Ghost Editor', sub: 'Professional writing tool', icon: Plus, action: () => window.location.href = '/editor', color: 'from-emerald-400/20 to-emerald-400/5 border-emerald-400/20' },
|
||||||
|
{ label: 'View Messages', sub: `${stats.unreadEmails} unread`, icon: Mail, action: () => setActiveTab('emails'), color: 'from-purple-400/20 to-purple-400/5 border-purple-400/20' },
|
||||||
|
{ label: 'Analytics', sub: 'View detailed stats', icon: TrendingUp, action: () => setActiveTab('analytics'), color: 'from-sky-400/20 to-sky-400/5 border-sky-400/20' },
|
||||||
|
{ label: 'Settings', sub: 'System configuration', icon: Settings, action: () => setActiveTab('settings'), color: 'from-stone-400/20 to-stone-400/5 border-stone-400/20' },
|
||||||
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = '/editor'}
|
key={item.label}
|
||||||
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
|
onClick={item.action}
|
||||||
|
className={`w-full flex items-center gap-3 p-3 bg-gradient-to-r ${item.color} border rounded-2xl hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 text-left`}
|
||||||
>
|
>
|
||||||
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
|
<div className="w-9 h-9 bg-white dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 flex items-center justify-center flex-shrink-0">
|
||||||
<Plus size={18} className="text-stone-600" />
|
<item.icon size={15} className="text-stone-600 dark:text-stone-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-stone-800 font-medium text-sm">Ghost Editor</p>
|
<p className="text-stone-900 dark:text-stone-50 font-bold text-sm">{item.label}</p>
|
||||||
<p className="text-stone-500 text-xs">Professional writing tool</p>
|
<p className="text-stone-400 text-xs">{item.sub}</p>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('analytics')}
|
|
||||||
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
|
|
||||||
<Activity size={18} className="text-stone-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-stone-800 font-medium text-sm">Reset Analytics</p>
|
|
||||||
<p className="text-stone-500 text-xs">Clear analytics data</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('emails')}
|
|
||||||
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
|
|
||||||
<Mail size={18} className="text-stone-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-stone-800 font-medium text-sm">View Messages</p>
|
|
||||||
<p className="text-stone-500 text-xs">{stats.unreadEmails} unread messages</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('analytics')}
|
|
||||||
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
|
|
||||||
<TrendingUp size={18} className="text-stone-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-stone-800 font-medium text-sm">Analytics</p>
|
|
||||||
<p className="text-stone-500 text-xs">View detailed statistics</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('settings')}
|
|
||||||
className="w-full flex items-center space-x-3 p-3 bg-stone-50 border border-stone-100 rounded-lg hover:shadow-sm hover:bg-white transition-all duration-200 text-left group"
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 bg-white rounded-lg border border-stone-100 flex items-center justify-center group-hover:border-stone-300 transition-colors">
|
|
||||||
<Settings size={18} className="text-stone-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-stone-800 font-medium text-sm">Settings</p>
|
|
||||||
<p className="text-stone-500 text-xs">System configuration</p>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,13 +565,12 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
|||||||
|
|
||||||
{activeTab === 'projects' && (
|
{activeTab === 'projects' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-stone-900">Project Management</h2>
|
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
||||||
<p className="text-stone-500 mt-1">Manage your portfolio projects</p>
|
Projects<span className="text-emerald-500">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">Manage your portfolio projects</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ProjectManager projects={projects} onProjectsChange={loadProjects} />
|
<ProjectManager projects={projects} onProjectsChange={loadProjects} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -633,49 +590,48 @@ const ModernAdminDashboard: React.FC<ModernAdminDashboardProps> = ({ isAuthentic
|
|||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-stone-900">System Settings</h1>
|
<h1 className="text-4xl sm:text-5xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
||||||
<p className="text-stone-500">Manage system configuration and preferences</p>
|
Settings<span className="text-emerald-500">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 mt-1 text-sm">Manage system configuration and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="admin-glass-card p-6 rounded-xl">
|
<div className="bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
|
||||||
<h2 className="text-xl font-bold text-stone-900 mb-4">Import / Export</h2>
|
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50 mb-2">
|
||||||
<p className="text-stone-500 mb-4">Backup and restore your portfolio data</p>
|
Import / Export
|
||||||
|
</h2>
|
||||||
|
<p className="text-stone-400 text-sm mb-6">Backup and restore your portfolio data</p>
|
||||||
<ImportExport />
|
<ImportExport />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-glass-card p-6 rounded-xl">
|
<div className="bg-white dark:bg-stone-900 border border-stone-100 dark:border-stone-800 rounded-3xl p-6">
|
||||||
<h2 className="text-xl font-bold text-stone-900 mb-4">System Status</h2>
|
<h2 className="text-lg font-black tracking-tight uppercase text-stone-900 dark:text-stone-50 mb-6">
|
||||||
<div className="space-y-4">
|
System Status
|
||||||
<div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
|
</h2>
|
||||||
<span className="text-stone-600">Database</span>
|
<div className="space-y-3">
|
||||||
<div className="flex items-center space-x-3">
|
{[
|
||||||
<div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
|
{ label: 'Database', color: 'bg-emerald-400/20 border-emerald-400/20', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
<span className="text-green-600 font-medium">Online</span>
|
{ label: 'Redis Cache', color: 'bg-emerald-400/20 border-emerald-400/20', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
</div>
|
{ label: 'API Services', color: 'bg-emerald-400/20 border-emerald-400/20', dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
</div>
|
].map((item) => (
|
||||||
<div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
|
<div
|
||||||
<span className="text-stone-600">Redis Cache</span>
|
key={item.label}
|
||||||
<div className="flex items-center space-x-3">
|
className={`flex items-center justify-between p-4 bg-gradient-to-r ${item.color} border rounded-2xl`}
|
||||||
<div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
|
>
|
||||||
<span className="text-green-600 font-medium">Online</span>
|
<span className="text-stone-600 dark:text-stone-300 font-medium text-sm">{item.label}</span>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<div className={`w-2 h-2 ${item.dot} rounded-full animate-pulse`} />
|
||||||
<div className="flex items-center justify-between p-3 bg-stone-50 rounded-lg border border-stone-100">
|
<span className={`${item.text} font-bold text-sm`}>Online</span>
|
||||||
<span className="text-stone-600">API Services</span>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="w-4 h-4 bg-green-500 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-green-600 font-medium">Online</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ This document provides an assessment of the portfolio website's production readi
|
|||||||
- [x] Input sanitization on forms
|
- [x] Input sanitization on forms
|
||||||
- [x] SQL injection protection (Prisma ORM)
|
- [x] SQL injection protection (Prisma ORM)
|
||||||
- [x] XSS protection via React and sanitize-html
|
- [x] XSS protection via React and sanitize-html
|
||||||
- [x] Error monitoring with Sentry.io
|
- [x] Error logging in development mode
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- [x] Next.js App Router with Server Components
|
- [x] Next.js App Router with Server Components
|
||||||
@@ -42,10 +42,8 @@ This document provides an assessment of the portfolio website's production readi
|
|||||||
- [x] Analytics opt-in (Umami - privacy-friendly)
|
- [x] Analytics opt-in (Umami - privacy-friendly)
|
||||||
- [x] Data processing documentation
|
- [x] Data processing documentation
|
||||||
- [x] Contact form with consent
|
- [x] Contact form with consent
|
||||||
- [x] Sentry.io mentioned in privacy policy
|
|
||||||
|
|
||||||
### Monitoring & Observability
|
### Monitoring & Observability
|
||||||
- [x] Sentry.io error tracking (configured)
|
|
||||||
- [x] Umami analytics (self-hosted, privacy-friendly)
|
- [x] Umami analytics (self-hosted, privacy-friendly)
|
||||||
- [x] Health check endpoint (`/api/health`)
|
- [x] Health check endpoint (`/api/health`)
|
||||||
- [x] Logging infrastructure
|
- [x] Logging infrastructure
|
||||||
@@ -79,15 +77,6 @@ This document provides an assessment of the portfolio website's production readi
|
|||||||
- Locations: Hero.tsx, CurrentlyReading.tsx, Projects pages
|
- Locations: Hero.tsx, CurrentlyReading.tsx, Projects pages
|
||||||
- Benefit: Better performance, automatic optimization
|
- Benefit: Better performance, automatic optimization
|
||||||
|
|
||||||
2. **Configure Sentry.io DSN**
|
|
||||||
- Set `NEXT_PUBLIC_SENTRY_DSN` in production environment
|
|
||||||
- Set `SENTRY_AUTH_TOKEN` for source map uploads
|
|
||||||
- Get DSN from: https://sentry.io/settings/dk0/projects/portfolio/keys/
|
|
||||||
|
|
||||||
3. **Review CSP for Sentry**
|
|
||||||
- May need to adjust Content-Security-Policy headers to allow Sentry
|
|
||||||
- Add `connect-src` directive for `*.sentry.io`
|
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
1. **Accessibility audit**
|
1. **Accessibility audit**
|
||||||
- Run Lighthouse audit
|
- Run Lighthouse audit
|
||||||
@@ -105,7 +94,6 @@ This document provides an assessment of the portfolio website's production readi
|
|||||||
|
|
||||||
### Low Priority
|
### Low Priority
|
||||||
1. **Enhanced monitoring**
|
1. **Enhanced monitoring**
|
||||||
- Custom Sentry contexts for better debugging
|
|
||||||
- Performance metrics dashboard
|
- Performance metrics dashboard
|
||||||
|
|
||||||
2. **Advanced features**
|
2. **Advanced features**
|
||||||
@@ -123,10 +111,6 @@ Before deploying to production:
|
|||||||
DATABASE_URL=postgresql://...
|
DATABASE_URL=postgresql://...
|
||||||
REDIS_URL=redis://...
|
REDIS_URL=redis://...
|
||||||
|
|
||||||
# Sentry (Recommended)
|
|
||||||
NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/...
|
|
||||||
SENTRY_AUTH_TOKEN=...
|
|
||||||
|
|
||||||
# Email (Optional)
|
# Email (Optional)
|
||||||
MY_EMAIL=...
|
MY_EMAIL=...
|
||||||
MY_PASSWORD=...
|
MY_PASSWORD=...
|
||||||
@@ -156,7 +140,6 @@ Before deploying to production:
|
|||||||
- Test HTTPS redirect
|
- Test HTTPS redirect
|
||||||
|
|
||||||
6. **Monitoring**
|
6. **Monitoring**
|
||||||
- Verify Sentry is receiving events
|
|
||||||
- Check Umami analytics tracking
|
- Check Umami analytics tracking
|
||||||
- Test health endpoint
|
- Test health endpoint
|
||||||
|
|
||||||
@@ -200,13 +183,12 @@ The application is production-ready with the following notes:
|
|||||||
3. **Performance**: Optimized for production
|
3. **Performance**: Optimized for production
|
||||||
4. **SEO**: Properly configured for search engines
|
4. **SEO**: Properly configured for search engines
|
||||||
5. **Privacy**: GDPR-compliant with privacy policy
|
5. **Privacy**: GDPR-compliant with privacy policy
|
||||||
6. **Monitoring**: Sentry.io configured (needs DSN in production)
|
6. **Monitoring**: Umami analytics (self-hosted)
|
||||||
|
|
||||||
**Next Steps**:
|
**Next Steps**:
|
||||||
1. Configure Sentry.io DSN in production environment
|
1. Replace `<img>` tags with Next.js `<Image />` for optimal performance
|
||||||
2. Replace `<img>` tags with Next.js `<Image />` for optimal performance
|
2. Run final accessibility audit
|
||||||
3. Run final accessibility audit
|
3. Monitor performance metrics after deployment
|
||||||
4. Monitor performance metrics after deployment
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,4 @@ PRISMA_AUTO_BASELINE=false
|
|||||||
# SKIP_PRISMA_MIGRATE=true
|
# SKIP_PRISMA_MIGRATE=true
|
||||||
|
|
||||||
# Monitoring (optional)
|
# Monitoring (optional)
|
||||||
NEXT_PUBLIC_SENTRY_DSN=your-sentry-dsn
|
|
||||||
SENTRY_AUTH_TOKEN=your-sentry-auth-token
|
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -1,32 +1,2 @@
|
|||||||
// This file configures the initialization of Sentry on the client.
|
// Client-side instrumentation hook for Next.js
|
||||||
// The added config here will be used whenever a users loads a page in their browser.
|
// Add any client-side instrumentation here if needed
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
// DSN from environment variable with fallback to wizard-provided value
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
|
||||||
|
|
||||||
// Add optional integrations for additional features
|
|
||||||
integrations: [Sentry.replayIntegration()],
|
|
||||||
|
|
||||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
|
||||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
|
||||||
// Enable logs to be sent to Sentry
|
|
||||||
enableLogs: true,
|
|
||||||
|
|
||||||
// Define how likely Replay events are sampled.
|
|
||||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
|
||||||
// in development and sample at a lower rate in production
|
|
||||||
replaysSessionSampleRate: 0.1,
|
|
||||||
|
|
||||||
// Define how likely Replay events are sampled when an error occurs.
|
|
||||||
replaysOnErrorSampleRate: 1.0,
|
|
||||||
|
|
||||||
// Enable sending user PII (Personally Identifiable Information)
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii
|
|
||||||
sendDefaultPii: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
import * as Sentry from '@sentry/nextjs';
|
|
||||||
|
|
||||||
export async function register() {
|
export async function register() {
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
// Instrumentation hook for Next.js
|
||||||
await import('./sentry.server.config');
|
// Add any server-side instrumentation here if needed
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
|
||||||
await import('./sentry.edge.config');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onRequestError = Sentry.captureRequestError;
|
|
||||||
|
|||||||
144
lib/analytics.ts
144
lib/analytics.ts
@@ -1,144 +0,0 @@
|
|||||||
// Analytics utilities for Umami with Performance Tracking
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
umami?: {
|
|
||||||
track: (event: string, data?: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PerformanceMetric {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
url: string;
|
|
||||||
timestamp: number;
|
|
||||||
userAgent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebVitalsMetric {
|
|
||||||
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
|
||||||
value: number;
|
|
||||||
delta: number;
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track custom events to Umami
|
|
||||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const trackFn = window.umami?.track;
|
|
||||||
if (typeof trackFn !== "function") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
trackFn(event, {
|
|
||||||
...data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - analytics must never break the app
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.warn("Error tracking Umami event:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track performance metrics
|
|
||||||
export const trackPerformance = (metric: PerformanceMetric) => {
|
|
||||||
trackEvent('performance', {
|
|
||||||
metric: metric.name,
|
|
||||||
value: Math.round(metric.value),
|
|
||||||
url: metric.url,
|
|
||||||
userAgent: metric.userAgent,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track Web Vitals
|
|
||||||
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
|
||||||
trackEvent('web-vitals', {
|
|
||||||
name: metric.name,
|
|
||||||
value: Math.round(metric.value),
|
|
||||||
delta: Math.round(metric.delta),
|
|
||||||
id: metric.id,
|
|
||||||
url: metric.url,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track page load performance
|
|
||||||
export const trackPageLoad = () => {
|
|
||||||
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const navigationEntries = performance.getEntriesByType('navigation');
|
|
||||||
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
|
|
||||||
|
|
||||||
if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
|
|
||||||
trackPerformance({
|
|
||||||
name: 'page-load',
|
|
||||||
value: navigation.loadEventEnd - navigation.fetchStart,
|
|
||||||
url: window.location.pathname,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track individual timing phases
|
|
||||||
trackEvent('page-timing', {
|
|
||||||
dns: navigation.domainLookupEnd && navigation.domainLookupStart
|
|
||||||
? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
|
|
||||||
: 0,
|
|
||||||
tcp: navigation.connectEnd && navigation.connectStart
|
|
||||||
? Math.round(navigation.connectEnd - navigation.connectStart)
|
|
||||||
: 0,
|
|
||||||
request: navigation.responseStart && navigation.requestStart
|
|
||||||
? Math.round(navigation.responseStart - navigation.requestStart)
|
|
||||||
: 0,
|
|
||||||
response: navigation.responseEnd && navigation.responseStart
|
|
||||||
? Math.round(navigation.responseEnd - navigation.responseStart)
|
|
||||||
: 0,
|
|
||||||
dom: navigation.domContentLoadedEventEnd && navigation.responseEnd
|
|
||||||
? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd)
|
|
||||||
: 0,
|
|
||||||
load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd
|
|
||||||
? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd)
|
|
||||||
: 0,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - performance tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking page load:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track API response times
|
|
||||||
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('api-call', {
|
|
||||||
endpoint,
|
|
||||||
duration: Math.round(duration),
|
|
||||||
status,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track user interactions
|
|
||||||
export const trackInteraction = (action: string, element?: string) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('interaction', {
|
|
||||||
action,
|
|
||||||
element,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track errors
|
|
||||||
export const trackError = (error: string, context?: string) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('error', {
|
|
||||||
error,
|
|
||||||
context,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -196,9 +196,9 @@ if (typeof window === 'undefined') {
|
|||||||
}, 60000); // Clear every minute
|
}, 60000); // Clear every minute
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const key = `admin_${ip}`;
|
const key = `${prefix}_${ip}`;
|
||||||
|
|
||||||
const current = rateLimitMap.get(key);
|
const current = rateLimitMap.get(key);
|
||||||
|
|
||||||
@@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> {
|
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record<string, string> {
|
||||||
const current = rateLimitMap.get(`admin_${ip}`);
|
const current = rateLimitMap.get(`${prefix}_${ip}`);
|
||||||
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -993,3 +993,83 @@ export async function getSnippets(limit = 10, featured?: boolean): Promise<Snipp
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Hardcover → Directus sync helpers ───────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BookReviewCreate {
|
||||||
|
hardcover_id: string;
|
||||||
|
book_title: string;
|
||||||
|
book_author: string;
|
||||||
|
book_image?: string;
|
||||||
|
rating?: number;
|
||||||
|
finished_at?: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a book review already exists in Directus by Hardcover ID.
|
||||||
|
* Used for deduplication during sync.
|
||||||
|
*/
|
||||||
|
export async function getBookReviewByHardcoverId(
|
||||||
|
hardcoverId: string
|
||||||
|
): Promise<{ id: string } | null> {
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
book_reviews(
|
||||||
|
filter: { hardcover_id: { _eq: "${hardcoverId}" } }
|
||||||
|
limit: 1
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest<{ book_reviews: Array<{ id: string }> }>(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
const item = result?.book_reviews?.[0];
|
||||||
|
return item ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new book review in the Directus book_reviews collection via REST API.
|
||||||
|
* Returns the created item id, or null on failure.
|
||||||
|
*/
|
||||||
|
export async function createBookReview(
|
||||||
|
data: BookReviewCreate
|
||||||
|
): Promise<{ id: string } | null> {
|
||||||
|
if (!DIRECTUS_TOKEN) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${DIRECTUS_URL}/items/book_reviews`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error(`Directus create book_review failed ${response.status}:`, text.slice(0, 200));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result?.data?.id ? { id: result.data.id } : null;
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('createBookReview error:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ function getCached(key: string): unknown | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a localized message by key
|
* Get a localized message by key.
|
||||||
* Tries: Directus (requested locale) → Directus (EN) → JSON (requested locale) → JSON (EN)
|
* Tries: JSON (requested locale) → JSON (EN) → Directus (requested locale) → Directus (EN)
|
||||||
|
*
|
||||||
|
* JSON is checked first so that translation-heavy server components never wait on
|
||||||
|
* a Directus network round-trip for keys that already exist in the message files.
|
||||||
|
* Directus is only queried when the key is absent from JSON (i.e. CMS-only content).
|
||||||
*/
|
*/
|
||||||
export async function getLocalizedMessage(
|
export async function getLocalizedMessage(
|
||||||
key: string,
|
key: string,
|
||||||
@@ -40,31 +44,16 @@ export async function getLocalizedMessage(
|
|||||||
const cached = getCached(cacheKey);
|
const cached = getCached(cacheKey);
|
||||||
if (cached !== null) return cached as string;
|
if (cached !== null) return cached as string;
|
||||||
|
|
||||||
// Try Directus with requested locale
|
|
||||||
const dbValue = await getMessage(key, locale);
|
|
||||||
if (dbValue) {
|
|
||||||
setCached(cacheKey, dbValue);
|
|
||||||
return dbValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to EN in Directus if not EN already
|
|
||||||
if (locale !== 'en') {
|
|
||||||
const dbValueEn = await getMessage(key, 'en');
|
|
||||||
if (dbValueEn) {
|
|
||||||
setCached(cacheKey, dbValueEn);
|
|
||||||
return dbValueEn;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to JSON file (normalize locale to 'en' or 'de')
|
|
||||||
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
||||||
|
|
||||||
|
// 1) JSON – requested locale
|
||||||
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
|
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
|
||||||
if (jsonValue) {
|
if (jsonValue) {
|
||||||
setCached(cacheKey, jsonValue);
|
setCached(cacheKey, jsonValue);
|
||||||
return jsonValue;
|
return jsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to EN JSON
|
// 2) JSON – EN fallback
|
||||||
if (normalizedLocale !== 'en') {
|
if (normalizedLocale !== 'en') {
|
||||||
const jsonValueEn = getNestedValue(jsonFallback['en'], key);
|
const jsonValueEn = getNestedValue(jsonFallback['en'], key);
|
||||||
if (jsonValueEn) {
|
if (jsonValueEn) {
|
||||||
@@ -73,7 +62,23 @@ export async function getLocalizedMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return the key itself
|
// 3) Directus – only for keys missing from JSON (CMS-only content)
|
||||||
|
const dbValue = await getMessage(key, locale);
|
||||||
|
if (dbValue) {
|
||||||
|
setCached(cacheKey, dbValue);
|
||||||
|
return dbValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Directus – EN fallback
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const dbValueEn = await getMessage(key, 'en');
|
||||||
|
if (dbValueEn) {
|
||||||
|
setCached(cacheKey, dbValueEn);
|
||||||
|
return dbValueEn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Return the key itself as last resort
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { trackWebVitals, trackPerformance } from './analytics';
|
|
||||||
|
|
||||||
// Web Vitals types
|
|
||||||
interface Metric {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
delta: number;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
|
||||||
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let clsValue = 0;
|
|
||||||
let sessionValue = 0;
|
|
||||||
let sessionEntries: PerformanceEntry[] = [];
|
|
||||||
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
|
|
||||||
const firstSessionEntry = sessionEntries[0];
|
|
||||||
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
|
||||||
|
|
||||||
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
|
||||||
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
||||||
sessionEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
||||||
sessionEntries = [entry];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionValue > clsValue) {
|
|
||||||
clsValue = sessionValue;
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'CLS',
|
|
||||||
value: clsValue,
|
|
||||||
delta: clsValue,
|
|
||||||
id: `cls-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - CLS tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('CLS tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'layout-shift', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('CLS observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
|
|
||||||
if (processingStart !== undefined) {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'FID',
|
|
||||||
value: processingStart - entry.startTime,
|
|
||||||
delta: processingStart - entry.startTime,
|
|
||||||
id: `fid-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FID tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'first-input', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FID observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
if (entry.name === 'first-contentful-paint') {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'FCP',
|
|
||||||
value: entry.startTime,
|
|
||||||
delta: entry.startTime,
|
|
||||||
id: `fcp-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FCP tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'paint', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FCP observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
const entries = list.getEntries();
|
|
||||||
const lastEntry = entries[entries.length - 1];
|
|
||||||
|
|
||||||
if (lastEntry) {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'LCP',
|
|
||||||
value: lastEntry.startTime,
|
|
||||||
delta: lastEntry.startTime,
|
|
||||||
id: `lcp-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('LCP tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('LCP observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
if (entry.entryType === 'navigation') {
|
|
||||||
const navEntry = entry as PerformanceNavigationTiming;
|
|
||||||
if (navEntry.responseStart && navEntry.fetchStart) {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'TTFB',
|
|
||||||
value: navEntry.responseStart - navEntry.fetchStart,
|
|
||||||
delta: navEntry.responseStart - navEntry.fetchStart,
|
|
||||||
id: `ttfb-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('TTFB tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'navigation', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('TTFB observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Custom hook for Web Vitals tracking
|
|
||||||
export const useWebVitals = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Wrap everything in try-catch to prevent errors from breaking the app
|
|
||||||
try {
|
|
||||||
const safeNow = () => {
|
|
||||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
||||||
return performance.now();
|
|
||||||
}
|
|
||||||
return Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store web vitals for batch sending
|
|
||||||
const webVitals: Record<string, number> = {};
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
||||||
const projectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
const observers: PerformanceObserver[] = [];
|
|
||||||
|
|
||||||
const sendWebVitals = async () => {
|
|
||||||
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
|
|
||||||
try {
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'performance',
|
|
||||||
projectId: projectId,
|
|
||||||
page: path,
|
|
||||||
performance: {
|
|
||||||
fcp: webVitals.FCP || 0,
|
|
||||||
lcp: webVitals.LCP || 0,
|
|
||||||
cls: webVitals.CLS || 0,
|
|
||||||
fid: webVitals.FID || 0,
|
|
||||||
ttfb: webVitals.TTFB || 0,
|
|
||||||
loadTime: safeNow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error sending web vitals:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track Core Web Vitals
|
|
||||||
const clsObserver = getCLS((metric) => {
|
|
||||||
webVitals.CLS = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (clsObserver) observers.push(clsObserver);
|
|
||||||
|
|
||||||
const fidObserver = getFID((metric) => {
|
|
||||||
webVitals.FID = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (fidObserver) observers.push(fidObserver);
|
|
||||||
|
|
||||||
const fcpObserver = getFCP((metric) => {
|
|
||||||
webVitals.FCP = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (fcpObserver) observers.push(fcpObserver);
|
|
||||||
|
|
||||||
const lcpObserver = getLCP((metric) => {
|
|
||||||
webVitals.LCP = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (lcpObserver) observers.push(lcpObserver);
|
|
||||||
|
|
||||||
const ttfbObserver = getTTFB((metric) => {
|
|
||||||
webVitals.TTFB = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (ttfbObserver) observers.push(ttfbObserver);
|
|
||||||
|
|
||||||
// Track page load performance
|
|
||||||
const handleLoad = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
trackPerformance({
|
|
||||||
name: 'page-load-complete',
|
|
||||||
value: safeNow(),
|
|
||||||
url: window.location.pathname,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'complete') {
|
|
||||||
handleLoad();
|
|
||||||
} else {
|
|
||||||
window.addEventListener('load', handleLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup all observers
|
|
||||||
observers.forEach(observer => {
|
|
||||||
try {
|
|
||||||
observer.disconnect();
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
window.removeEventListener('load', handleLoad);
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// If Web Vitals initialization fails, don't break the app
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Web Vitals initialization failed:', error);
|
|
||||||
}
|
|
||||||
// Return empty cleanup function
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
"badge": "Student & Self-Hoster",
|
||||||
|
"line1": "Building",
|
||||||
|
"line2": "Stuff.",
|
||||||
"features": {
|
"features": {
|
||||||
"f1": "Next.js & Flutter",
|
"f1": "Next.js & Flutter",
|
||||||
"f2": "Docker Swarm & CI/CD",
|
"f2": "Docker Swarm & CI/CD",
|
||||||
@@ -69,7 +72,8 @@
|
|||||||
"title": "Gelesene Bücher",
|
"title": "Gelesene Bücher",
|
||||||
"finishedAt": "Beendet am",
|
"finishedAt": "Beendet am",
|
||||||
"showMore": "{count} weitere anzeigen",
|
"showMore": "{count} weitere anzeigen",
|
||||||
"showLess": "Weniger anzeigen"
|
"showLess": "Weniger anzeigen",
|
||||||
|
"empty": "In Hardcover fertig gelesene Bücher erscheinen hier automatisch."
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
"idleStatus": "System im Leerlauf / Geist aktiv",
|
"idleStatus": "System im Leerlauf / Geist aktiv",
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
,
|
,
|
||||||
"home": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
"badge": "Student & Self-Hoster",
|
||||||
|
"line1": "Building",
|
||||||
|
"line2": "Stuff.",
|
||||||
"features": {
|
"features": {
|
||||||
"f1": "Next.js & Flutter",
|
"f1": "Next.js & Flutter",
|
||||||
"f2": "Docker Swarm & CI/CD",
|
"f2": "Docker Swarm & CI/CD",
|
||||||
@@ -70,7 +73,8 @@
|
|||||||
"title": "Read",
|
"title": "Read",
|
||||||
"finishedAt": "Finished",
|
"finishedAt": "Finished",
|
||||||
"showMore": "{count} more",
|
"showMore": "{count} more",
|
||||||
"showLess": "Show less"
|
"showLess": "Show less",
|
||||||
|
"empty": "Books finished in Hardcover will appear here automatically."
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
"idleStatus": "System Idle / Mind Active",
|
"idleStatus": "System Idle / Mind Active",
|
||||||
|
|||||||
@@ -70,9 +70,7 @@ export function middleware(request: NextRequest) {
|
|||||||
pathname.startsWith("/api/") ||
|
pathname.startsWith("/api/") ||
|
||||||
pathname === "/api" ||
|
pathname === "/api" ||
|
||||||
pathname.startsWith("/manage") ||
|
pathname.startsWith("/manage") ||
|
||||||
pathname.startsWith("/editor") ||
|
pathname.startsWith("/editor");
|
||||||
pathname === "/sentry-example-page" ||
|
|
||||||
pathname.startsWith("/sentry-example-page/");
|
|
||||||
|
|
||||||
// Locale routing for public site pages
|
// Locale routing for public site pages
|
||||||
const responseUrl = request.nextUrl.clone();
|
const responseUrl = request.nextUrl.clone();
|
||||||
@@ -88,11 +86,15 @@ export function middleware(request: NextRequest) {
|
|||||||
return addHeaders(request, res);
|
return addHeaders(request, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect bare routes to locale-prefixed ones
|
// Add locale prefix: rewrite root path (avoids redirect roundtrip), redirect others
|
||||||
const preferred = pickLocaleFromHeader(request.headers.get("accept-language"));
|
const preferred = pickLocaleFromHeader(request.headers.get("accept-language"));
|
||||||
const redirectTarget =
|
if (pathname === "/") {
|
||||||
pathname === "/" ? `/${preferred}` : `/${preferred}${pathname}${search || ""}`;
|
responseUrl.pathname = `/${preferred}`;
|
||||||
responseUrl.pathname = redirectTarget;
|
const res = NextResponse.rewrite(responseUrl);
|
||||||
|
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
|
||||||
|
return addHeaders(request, res);
|
||||||
|
}
|
||||||
|
responseUrl.pathname = `/${preferred}${pathname}${search || ""}`;
|
||||||
const res = NextResponse.redirect(responseUrl);
|
const res = NextResponse.redirect(responseUrl);
|
||||||
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
|
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
|
||||||
return addHeaders(request, res);
|
return addHeaders(request, res);
|
||||||
|
|||||||
103
next.config.ts
103
next.config.ts
@@ -3,8 +3,6 @@ import dotenv from "dotenv";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import bundleAnalyzer from "@next/bundle-analyzer";
|
import bundleAnalyzer from "@next/bundle-analyzer";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
|
||||||
|
|
||||||
// Load the .env file from the working directory
|
// Load the .env file from the working directory
|
||||||
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
dotenv.config({ path: path.resolve(process.cwd(), ".env") });
|
||||||
|
|
||||||
@@ -33,20 +31,22 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
experimental:
|
experimental: {
|
||||||
process.env.NODE_ENV === "production"
|
// Tree-shake barrel-file packages in both dev and production
|
||||||
? {
|
optimizePackageImports: ["lucide-react", "framer-motion", "react-icons", "@tiptap/react"],
|
||||||
optimizePackageImports: ["lucide-react", "framer-motion"],
|
// Merge all CSS into a single chunk to eliminate the render-blocking CSS chain
|
||||||
}
|
// (84dc7384.css → 3aefc04b.css sequential dependency reported by PageSpeed).
|
||||||
: {
|
cssChunking: false,
|
||||||
// In development, enable webpack build worker for faster builds
|
// Note: optimizeCss (critters) is intentionally disabled — it converts the main
|
||||||
webpackBuildWorker: true,
|
// <link rel="stylesheet"> to a JS-deferred preload, which PageSpeed reads as a
|
||||||
|
// sequential CSS chain and reports 410ms of render-blocking.
|
||||||
|
...(process.env.NODE_ENV !== "production" ? { webpackBuildWorker: true } : {}),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
formats: ["image/webp", "image/avif"],
|
formats: ["image/webp", "image/avif"],
|
||||||
minimumCacheTTL: 60,
|
minimumCacheTTL: 2592000,
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
@@ -97,17 +97,6 @@ const nextConfig: NextConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!isServer) {
|
if (!isServer) {
|
||||||
// Optimize module concatenation and chunking for the client build
|
|
||||||
config.optimization = {
|
|
||||||
...config.optimization,
|
|
||||||
moduleIds: "deterministic",
|
|
||||||
chunkIds: "deterministic",
|
|
||||||
splitChunks: {
|
|
||||||
...config.optimization?.splitChunks,
|
|
||||||
maxSize: 200000, // keep chunks <200KB to avoid large-string serialization
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Suppress framer-motion source map errors in development
|
// Suppress framer-motion source map errors in development
|
||||||
config.plugins.push(
|
config.plugins.push(
|
||||||
new webpack.SourceMapDevToolPlugin({
|
new webpack.SourceMapDevToolPlugin({
|
||||||
@@ -169,7 +158,35 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
source: "/api/(.*)",
|
// Allow bfcache for n8n routes: use no-cache (revalidate) instead of no-store
|
||||||
|
source: "/api/n8n/(.*)",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value: "no-cache, must-revalidate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/auth/(.*)",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/email/(.*)",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "Cache-Control",
|
||||||
|
value: "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/api/contacts/(.*)",
|
||||||
headers: [
|
headers: [
|
||||||
{
|
{
|
||||||
key: "Cache-Control",
|
key: "Cache-Control",
|
||||||
@@ -200,42 +217,4 @@ const withBundleAnalyzer = bundleAnalyzer({
|
|||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
|
||||||
|
|
||||||
// Wrap with Sentry
|
export default withBundleAnalyzer(withNextIntl(nextConfig));
|
||||||
export default withSentryConfig(
|
|
||||||
withBundleAnalyzer(withNextIntl(nextConfig)),
|
|
||||||
{
|
|
||||||
// For all available options, see:
|
|
||||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
|
||||||
|
|
||||||
org: "dk0",
|
|
||||||
project: "portfolio",
|
|
||||||
|
|
||||||
// Only print logs for uploading source maps in CI
|
|
||||||
silent: !process.env.CI,
|
|
||||||
|
|
||||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
|
||||||
widenClientFileUpload: true,
|
|
||||||
|
|
||||||
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
|
|
||||||
tunnelRoute: "/monitoring",
|
|
||||||
|
|
||||||
// Webpack-specific options
|
|
||||||
webpack: {
|
|
||||||
// Automatically annotate React components to show their full name in breadcrumbs and session replay
|
|
||||||
reactComponentAnnotation: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
|
||||||
treeshake: {
|
|
||||||
removeDebugLogging: true,
|
|
||||||
},
|
|
||||||
// Enables automatic instrumentation of Vercel Cron Monitors
|
|
||||||
automaticVercelMonitors: true,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Source maps configuration
|
|
||||||
sourcemaps: {
|
|
||||||
disable: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
3629
package-lock.json
generated
3629
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -54,10 +54,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/bundle-analyzer": "^15.1.7",
|
"@next/bundle-analyzer": "^15.1.7",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@swc/helpers": "^0.5.19",
|
||||||
"@react-three/fiber": "^9.5.0",
|
|
||||||
"@sentry/nextjs": "^10.36.0",
|
|
||||||
"@shadergradient/react": "^2.4.20",
|
|
||||||
"@tiptap/extension-color": "^3.15.3",
|
"@tiptap/extension-color": "^3.15.3",
|
||||||
"@tiptap/extension-highlight": "^3.15.3",
|
"@tiptap/extension-highlight": "^3.15.3",
|
||||||
"@tiptap/extension-link": "^3.15.3",
|
"@tiptap/extension-link": "^3.15.3",
|
||||||
@@ -66,16 +63,13 @@
|
|||||||
"@tiptap/html": "^3.15.3",
|
"@tiptap/html": "^3.15.3",
|
||||||
"@tiptap/react": "^3.15.3",
|
"@tiptap/react": "^3.15.3",
|
||||||
"@tiptap/starter-kit": "^3.15.3",
|
"@tiptap/starter-kit": "^3.15.3",
|
||||||
"@types/three": "^0.182.0",
|
|
||||||
"@vercel/og": "^0.6.5",
|
"@vercel/og": "^0.6.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"framer-motion": "^12.24.10",
|
"framer-motion": "^12.24.10",
|
||||||
"gray-matter": "^4.0.3",
|
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"next": "^15.5.7",
|
"next": "^15.5.7",
|
||||||
"next-intl": "^4.7.0",
|
"next-intl": "^4.7.0",
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
@@ -83,13 +77,16 @@
|
|||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-responsive-masonry": "^2.7.1",
|
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0"
|
||||||
"three": "^0.182.0",
|
|
||||||
"zod": "^4.3.5"
|
|
||||||
},
|
},
|
||||||
|
"browserslist": [
|
||||||
|
"chrome >= 100",
|
||||||
|
"firefox >= 100",
|
||||||
|
"safari >= 15.4",
|
||||||
|
"edge >= 100"
|
||||||
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@playwright/test": "^1.57.0",
|
"@playwright/test": "^1.57.0",
|
||||||
@@ -102,10 +99,9 @@
|
|||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-responsive-masonry": "^2.6.0",
|
|
||||||
"@types/react-syntax-highlighter": "^15.5.11",
|
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
|
"critters": "^0.0.23",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^15.5.7",
|
"eslint-config-next": "^15.5.7",
|
||||||
|
|||||||
@@ -134,7 +134,17 @@ async function main() {
|
|||||||
// If baseline fails we continue to migrate deploy, which will surface the real issue.
|
// If baseline fails we continue to migrate deploy, which will surface the real issue.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
|
|
||||||
|
const migrateResult = spawnSync(
|
||||||
|
"node",
|
||||||
|
["node_modules/prisma/build/index.js", "migrate", "deploy"],
|
||||||
|
{ stdio: "inherit", env: process.env }
|
||||||
|
);
|
||||||
|
if (migrateResult.status !== 0) {
|
||||||
|
console.error(
|
||||||
|
`[startup] prisma migrate deploy failed (exit ${migrateResult.status}). Starting server anyway...`
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
|
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
// This file configures the initialization of Sentry for edge features (middleware, etc).
|
|
||||||
// The config you add here will be used whenever the server handles a request.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
// DSN from environment variable with fallback to wizard-provided value
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
|
||||||
|
|
||||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
|
||||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
});
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// This file configures the initialization of Sentry on the server.
|
|
||||||
// The config you add here will be used whenever the server handles a request.
|
|
||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
|
|
||||||
Sentry.init({
|
|
||||||
// DSN from environment variable with fallback to wizard-provided value
|
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || "https://148e31ea874c60f3d2e0f70fd6701f6b@o4510751135105024.ingest.de.sentry.io/4510751388926032",
|
|
||||||
|
|
||||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
|
||||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
|
||||||
debug: false,
|
|
||||||
});
|
|
||||||
@@ -11,6 +11,20 @@ export default {
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(10px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
"0%": { opacity: "0", transform: "translateY(-20px)" },
|
||||||
|
"100%": { opacity: "1", transform: "translateY(0)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"fade-in": "fadeIn 0.5s ease-out",
|
||||||
|
"slide-down": "slideDown 0.4s ease-out",
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
background: "var(--background)",
|
background: "var(--background)",
|
||||||
foreground: "var(--foreground)",
|
foreground: "var(--foreground)",
|
||||||
|
|||||||
Reference in New Issue
Block a user