Compare commits
31 Commits
4029cd660d
...
689cfa18cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
689cfa18cf | ||
|
|
6fd4756f35 | ||
|
|
a5dba298f3 | ||
|
|
6f62b37c3a | ||
|
|
6213a4875a | ||
|
|
0684231308 | ||
|
|
739ee8a825 | ||
|
|
91eb446ac5 | ||
|
|
7955dfbabb | ||
|
|
7603cb6298 | ||
|
|
c3f55c92ed | ||
|
|
f5081f8765 | ||
|
|
b6eb24f2e8 | ||
|
|
9fd8c25dc6 | ||
|
|
cfd2f9f248 | ||
|
|
cd3726063c | ||
|
|
3cf1b9144d | ||
|
|
18f8fb7407 | ||
|
|
332adab08c | ||
|
|
5347a9ff3b | ||
|
|
0b1a45038d | ||
|
|
931843a5c6 | ||
|
|
0a0895cf89 | ||
|
|
5576e41ce0 | ||
|
|
cc8fff14d2 | ||
|
|
6998a0e7a1 | ||
|
|
0766b46cc8 | ||
|
|
92e5b4936e | ||
|
|
99d0d1dba1 | ||
|
|
032568562c | ||
|
|
07741761cc |
211
.github/copilot-instructions.md
vendored
Normal file
211
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
npm run build # Production build (standalone mode)
|
||||||
|
npm run start # Start production server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Unit tests (Jest)
|
||||||
|
npm run test # Run all unit tests
|
||||||
|
npm run test:watch # Watch mode
|
||||||
|
npm run test:coverage # With coverage report
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```bash
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
npm run lint:fix # Auto-fix issues
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database (Prisma)
|
||||||
|
```bash
|
||||||
|
npm run db:generate # Generate Prisma client
|
||||||
|
npm run db:push # Push schema to database
|
||||||
|
npm run db:studio # Open Prisma Studio
|
||||||
|
npm run db:seed # Seed database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
|
||||||
|
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
||||||
|
- **Theming**: next-themes for dark mode (system/light/dark)
|
||||||
|
- **Animations**: Framer Motion 12
|
||||||
|
- **3D**: Three.js + React Three Fiber (shader gradient background)
|
||||||
|
- **Database**: PostgreSQL via Prisma ORM
|
||||||
|
- **Cache**: Redis (optional)
|
||||||
|
- **CMS**: Directus (self-hosted, GraphQL, optional)
|
||||||
|
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
||||||
|
- **i18n**: next-intl (EN + DE)
|
||||||
|
- **Monitoring**: Sentry
|
||||||
|
- **Deployment**: Docker (standalone mode) + Nginx
|
||||||
|
|
||||||
|
### Key Directories
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
[locale]/ # i18n routes (en, de)
|
||||||
|
page.tsx # Homepage sections
|
||||||
|
projects/ # Project listing + detail pages
|
||||||
|
api/ # API routes
|
||||||
|
book-reviews/ # Book reviews from Directus
|
||||||
|
hobbies/ # Hobbies from Directus
|
||||||
|
n8n/ # n8n webhook proxies
|
||||||
|
projects/ # Projects (PostgreSQL + Directus)
|
||||||
|
tech-stack/ # Tech stack from Directus
|
||||||
|
components/ # React components
|
||||||
|
lib/
|
||||||
|
directus.ts # Directus GraphQL client (no SDK)
|
||||||
|
auth.ts # Auth + rate limiting
|
||||||
|
translations-loader.ts # i18n loaders for server components
|
||||||
|
prisma/
|
||||||
|
schema.prisma # Database schema
|
||||||
|
messages/
|
||||||
|
en.json # English translations
|
||||||
|
de.json # German translations
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### CMS Integration (Directus)
|
||||||
|
- GraphQL calls via `lib/directus.ts` (no Directus SDK)
|
||||||
|
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
||||||
|
- Translations use Directus native system (M2O to `languages`)
|
||||||
|
- 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`)
|
||||||
|
|
||||||
|
### n8n Integration
|
||||||
|
- Webhook base URL: `N8N_WEBHOOK_URL` env var
|
||||||
|
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||||
|
- All endpoints have rate limiting and 10s timeout protection
|
||||||
|
- Hardcover reading data cached for 5 minutes
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### i18n (Internationalization)
|
||||||
|
- **Supported locales**: `en` (English), `de` (German)
|
||||||
|
- **Primary source**: Static JSON files in `messages/en.json` and `messages/de.json`
|
||||||
|
- **Optional override**: Directus CMS `messages` collection
|
||||||
|
- **Server components**: Use `getHeroTranslations()`, `getNavTranslations()`, etc. from `lib/translations-loader.ts`
|
||||||
|
- **Client components**: Use `useTranslations("key.path")` from next-intl
|
||||||
|
- **Locale mapping**: Middleware defines `["en", "de"]` which must match `app/[locale]/layout.tsx`
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
- **Client components**: Mark with `"use client"` for interactive/data-fetching parts
|
||||||
|
- **Data loading**: Use `useEffect` for client-side fetching on mount
|
||||||
|
- **Animations**: Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
|
||||||
|
- **Loading states**: Every async component needs a matching Skeleton component
|
||||||
|
|
||||||
|
### Design System ("Liquid Editorial Bento")
|
||||||
|
- **Core palette**: Cream (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`)
|
||||||
|
- **Custom colors**: Prefixed with `liquid-*` (sky, mint, lavender, pink, rose, peach, coral, teal, lime)
|
||||||
|
- **Card style**: Gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`)
|
||||||
|
- **Glassmorphism**: Use `backdrop-blur-sm` with `border-2` and `rounded-xl`
|
||||||
|
- **Typography**: Headlines uppercase, tracking-tighter, with accent point at end
|
||||||
|
- **Layout**: Bento Grid for new features (no floating overlays)
|
||||||
|
|
||||||
|
### File Naming
|
||||||
|
- **Components**: PascalCase in `app/components/` (e.g., `About.tsx`)
|
||||||
|
- **API routes**: kebab-case directories in `app/api/` (e.g., `book-reviews/`)
|
||||||
|
- **Lib utilities**: kebab-case in `lib/` (e.g., `email-obfuscate.ts`)
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
- **Language**: Code in English, user-facing text via i18n
|
||||||
|
- **TypeScript**: No `any` types - use interfaces from `lib/directus.ts` or `app/_ui/`
|
||||||
|
- **Error handling**: All API calls must catch errors with fallbacks
|
||||||
|
- **Error logging**: Only in development mode (`process.env.NODE_ENV === "development"`)
|
||||||
|
- **Commit messages**: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||||
|
- **No emojis**: Unless explicitly requested
|
||||||
|
|
||||||
|
### Testing Notes
|
||||||
|
- **Jest environment**: JSDOM with mocks for `window.matchMedia` and `IntersectionObserver`
|
||||||
|
- **Playwright**: Uses plain Next.js dev server (no Docker) with `NODE_ENV=development` to avoid Edge runtime issues
|
||||||
|
- **Transform**: ESM modules (react-markdown, remark-*, etc.) are transformed via `transformIgnorePatterns`
|
||||||
|
- **After UI changes**: Run `npm run test` to verify no regressions
|
||||||
|
|
||||||
|
### Docker & Deployment
|
||||||
|
- **Standalone mode**: `next.config.ts` uses `output: "standalone"` for optimized Docker builds
|
||||||
|
- **Branches**: `dev` → staging, `production` → live
|
||||||
|
- **CI/CD**: Gitea Actions (`.gitea/workflows/`)
|
||||||
|
- **Verify Docker builds**: Always test Docker builds after changes to `next.config.ts` or dependencies
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a CMS-managed section
|
||||||
|
1. Define GraphQL query + types in `lib/directus.ts`
|
||||||
|
2. Create API route in `app/api/<name>/route.ts` with `runtime='nodejs'` and `dynamic='force-dynamic'`
|
||||||
|
3. Create component in `app/components/<Name>.tsx`
|
||||||
|
4. Add i18n keys to `messages/en.json` and `messages/de.json`
|
||||||
|
5. Integrate into parent component
|
||||||
|
|
||||||
|
### Adding i18n strings
|
||||||
|
1. Add keys to both `messages/en.json` and `messages/de.json`
|
||||||
|
2. Use `useTranslations("key.path")` in client components
|
||||||
|
3. Use `getTranslations("key.path")` in server components
|
||||||
|
|
||||||
|
### Working with Directus
|
||||||
|
- All queries go through `directusRequest()` in `lib/directus.ts`
|
||||||
|
- Uses GraphQL endpoint (`/graphql`) with 2s timeout
|
||||||
|
- Returns `null` on failure (graceful degradation)
|
||||||
|
- Translations filtered by `languages_code.code` matching Directus locale
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### Required for CMS
|
||||||
|
```bash
|
||||||
|
DIRECTUS_URL=https://cms.dk0.dev
|
||||||
|
DIRECTUS_STATIC_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required for n8n features
|
||||||
|
```bash
|
||||||
|
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
||||||
|
N8N_SECRET_TOKEN=...
|
||||||
|
N8N_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database & Cache
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
REDIS_URL=redis://...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional
|
||||||
|
```bash
|
||||||
|
SENTRY_DSN=...
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://dk0.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation References
|
||||||
|
- Operations guide: `docs/OPERATIONS.md`
|
||||||
|
- Locale system: `docs/LOCALE_SYSTEM.md`
|
||||||
|
- CMS guide: `docs/CMS_GUIDE.md`
|
||||||
|
- Testing & deployment: `docs/TESTING_AND_DEPLOYMENT.md`
|
||||||
156
CLAUDE.md
Normal file
156
CLAUDE.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# CLAUDE.md - Portfolio Project Guide
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 15 (App Router), TypeScript 5.9
|
||||||
|
- **Styling**: Tailwind CSS 3.4 with custom `liquid-*` color tokens
|
||||||
|
- **Theming**: `next-themes` for Dark Mode support (system/light/dark)
|
||||||
|
- **Animations**: Framer Motion 12
|
||||||
|
- **3D**: Three.js + React Three Fiber (shader gradient background)
|
||||||
|
- **Database**: PostgreSQL via Prisma ORM
|
||||||
|
- **Cache**: Redis (optional)
|
||||||
|
- **CMS**: Directus (self-hosted, REST/GraphQL, optional)
|
||||||
|
- **Automation**: n8n webhooks (status, chat, hardcover, image generation)
|
||||||
|
- **i18n**: next-intl (EN + DE), message files in `messages/`
|
||||||
|
- **Monitoring**: Sentry
|
||||||
|
- **Deployment**: Docker + Nginx, CI via Gitea Actions
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Full dev environment (Docker + Next.js)
|
||||||
|
npm run dev:simple # Next.js only (no Docker)
|
||||||
|
npm run dev:next # Plain Next.js dev server
|
||||||
|
npm run build # Production build
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run test # Jest unit tests
|
||||||
|
npm run test:e2e # Playwright E2E tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
### Data Source Hierarchy (Fallback Chain)
|
||||||
|
1. Directus CMS (if configured via `DIRECTUS_STATIC_TOKEN`)
|
||||||
|
2. PostgreSQL (for projects, analytics)
|
||||||
|
3. JSON files (`messages/*.json`)
|
||||||
|
4. Hardcoded defaults
|
||||||
|
5. Display key itself as last resort
|
||||||
|
|
||||||
|
All external data sources fail gracefully - the site never crashes if Directus, PostgreSQL, n8n, or Redis are unavailable.
|
||||||
|
|
||||||
|
### CMS Integration (Directus)
|
||||||
|
- REST/GraphQL calls via `lib/directus.ts` (no Directus SDK)
|
||||||
|
- Collections: `tech_stack_categories`, `tech_stack_items`, `hobbies`, `content_pages`, `projects`, `book_reviews`
|
||||||
|
- Translations use Directus native translation system (M2O to `languages`)
|
||||||
|
- Locale mapping: `en` -> `en-US`, `de` -> `de-DE`
|
||||||
|
|
||||||
|
### n8n Integration
|
||||||
|
- Webhook base URL: `N8N_WEBHOOK_URL` env var
|
||||||
|
- Auth via `N8N_SECRET_TOKEN` and/or `N8N_API_KEY` headers
|
||||||
|
- All n8n endpoints have rate limiting and timeout protection (10s)
|
||||||
|
- Hardcover data cached for 5 minutes
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
- Client components with `"use client"` for interactive/data-fetching parts
|
||||||
|
- `useEffect` for data loading on mount
|
||||||
|
- `useTranslations` from next-intl for i18n
|
||||||
|
- Framer Motion `variants` pattern with `staggerContainer` + `fadeInUp`
|
||||||
|
- Gradient cards with `liquid-*` color tokens and `backdrop-blur-sm`
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
Custom Tailwind colors prefixed with `liquid-`:
|
||||||
|
- `liquid-sky`, `liquid-mint`, `liquid-lavender`, `liquid-pink`
|
||||||
|
- `liquid-rose`, `liquid-peach`, `liquid-coral`, `liquid-teal`, `liquid-lime`
|
||||||
|
|
||||||
|
Cards use gradient backgrounds (`bg-gradient-to-br from-liquid-*/15 via-liquid-*/10 to-liquid-*/15`) with `border-2` and `rounded-xl`.
|
||||||
|
|
||||||
|
## Key Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required for CMS
|
||||||
|
DIRECTUS_URL=https://cms.dk0.dev
|
||||||
|
DIRECTUS_STATIC_TOKEN=...
|
||||||
|
|
||||||
|
# Required for n8n features
|
||||||
|
N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
||||||
|
N8N_SECRET_TOKEN=...
|
||||||
|
N8N_API_KEY=...
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
REDIS_URL=redis://...
|
||||||
|
SENTRY_DSN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Language: Code in English, user-facing text via i18n (EN + DE)
|
||||||
|
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`)
|
||||||
|
- Components: PascalCase files in `app/components/`
|
||||||
|
- API routes: kebab-case directories in `app/api/`
|
||||||
|
- CMS data always has a static fallback - never rely solely on Directus
|
||||||
|
- Error logging: Only in `development` mode (`process.env.NODE_ENV === "development"`)
|
||||||
|
- No emojis in code unless explicitly requested
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a new CMS-managed section
|
||||||
|
1. Define the GraphQL query + types in `lib/directus.ts`
|
||||||
|
2. Create an API route in `app/api/<name>/route.ts`
|
||||||
|
3. Create a component in `app/components/<Name>.tsx`
|
||||||
|
4. Add i18n keys to `messages/en.json` and `messages/de.json`
|
||||||
|
5. Integrate into the parent component (usually `About.tsx`)
|
||||||
|
|
||||||
|
### Adding i18n strings
|
||||||
|
1. Add keys to `messages/en.json` and `messages/de.json`
|
||||||
|
2. Access via `useTranslations("key.path")` in client components
|
||||||
|
3. Or `getTranslations("key.path")` in server components
|
||||||
|
|
||||||
|
### Working with Directus collections
|
||||||
|
- All queries go through `directusRequest()` in `lib/directus.ts`
|
||||||
|
- Uses GraphQL endpoint (`/graphql`)
|
||||||
|
- 2-second timeout, graceful null fallback
|
||||||
|
- Translations filtered by `languages_code.code` matching Directus locale
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
# Directus Integration - Migration Guide
|
|
||||||
|
|
||||||
## 🎯 Overview
|
|
||||||
|
|
||||||
This portfolio now has a **hybrid i18n system**:
|
|
||||||
- ✅ **JSON Files** (Primary) → All translations work from `messages/*.json` files
|
|
||||||
- ✅ **Directus CMS** (Optional) → Can override translations dynamically without rebuilds
|
|
||||||
|
|
||||||
**Important**: Directus is **optional**. The app works perfectly fine without it using JSON fallbacks.
|
|
||||||
|
|
||||||
## 📁 New File Structure
|
|
||||||
|
|
||||||
### Core Infrastructure
|
|
||||||
- `lib/directus.ts` - REST Client for Directus (uses `de-DE`, `en-US` locale codes)
|
|
||||||
- `lib/i18n-loader.ts` - Loads texts with Fallback Chain
|
|
||||||
- `lib/translations-loader.ts` - Batch loader for all sections (cleaned up to match actual usage)
|
|
||||||
- `types/translations.ts` - TypeScript types for all translation objects (fixed to match components)
|
|
||||||
|
|
||||||
### Components
|
|
||||||
All component wrappers properly load and pass translations to client components.
|
|
||||||
|
|
||||||
## 🔄 How It Works
|
|
||||||
|
|
||||||
### Without Directus (Default)
|
|
||||||
```
|
|
||||||
Component → useTranslations("nav") → JSON File (messages/en.json)
|
|
||||||
```
|
|
||||||
|
|
||||||
### With Directus (Optional)
|
|
||||||
```
|
|
||||||
Server Component → getNavTranslations(locale)
|
|
||||||
→ Try Directus API (de-DE/en-US)
|
|
||||||
→ If not found: JSON File (de/en)
|
|
||||||
→ Props to Client Component
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🗄️ Directus Setup (Optional)
|
|
||||||
|
|
||||||
Only set this up if you want to edit translations through a CMS without rebuilding the app.
|
|
||||||
|
|
||||||
### 1. Environment Variables
|
|
||||||
|
|
||||||
Add to `.env.local`:
|
|
||||||
```bash
|
|
||||||
DIRECTUS_URL=https://cms.example.com
|
|
||||||
DIRECTUS_STATIC_TOKEN=your_token_here
|
|
||||||
```
|
|
||||||
|
|
||||||
**If these are not set**, the system will skip Directus and use JSON files only.
|
|
||||||
|
|
||||||
### 2. Collection: `messages`
|
|
||||||
|
|
||||||
Create a `messages` collection in Directus with these fields:
|
|
||||||
- `key` (String, required) - e.g., "nav.home"
|
|
||||||
- `translations` (Translations) - Directus native translations feature
|
|
||||||
- Configure languages: `en-US` and `de-DE`
|
|
||||||
|
|
||||||
**Note**: Keys use dot notation (`nav.home`) but locales use dashes (`en-US`, `de-DE`).
|
|
||||||
|
|
||||||
### 3. Permissions
|
|
||||||
|
|
||||||
Grant **Public** role read access to `messages` collection.
|
|
||||||
|
|
||||||
## 📝 Translation Keys
|
|
||||||
|
|
||||||
See `docs/LOCALE_SYSTEM.md` for the complete list of translation keys and their structure.
|
|
||||||
|
|
||||||
All keys are organized hierarchically:
|
|
||||||
- `nav.*` - Navigation items
|
|
||||||
- `home.hero.*` - Hero section
|
|
||||||
- `home.about.*` - About section
|
|
||||||
- `home.projects.*` - Projects section
|
|
||||||
- `home.contact.*` - Contact form and info
|
|
||||||
- `footer.*` - Footer content
|
|
||||||
- `consent.*` - Privacy consent banner
|
|
||||||
|
|
||||||
## 🎨 Rich Text Content
|
|
||||||
|
|
||||||
For longer content that needs formatting (bold, italic, lists), use the `content_pages` collection:
|
|
||||||
|
|
||||||
### Collection: `content_pages` (Optional)
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `slug` (String, unique) - e.g., "home-hero"
|
|
||||||
- `locale` (String) - `en` or `de`
|
|
||||||
- `title` (String)
|
|
||||||
- `content` (Rich Text or Long Text)
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
- `home-hero` - Hero section description
|
|
||||||
- `home-about` - About section content
|
|
||||||
- `home-contact` - Contact intro text
|
|
||||||
|
|
||||||
Components fetch these via `/api/content/page` and render using `RichTextClient`.
|
|
||||||
|
|
||||||
## 🔍 Fallback Chain
|
|
||||||
|
|
||||||
For every translation key, the system searches in this order:
|
|
||||||
|
|
||||||
1. **Directus** (if configured) in requested locale (e.g., `de-DE`)
|
|
||||||
2. **Directus** in English fallback (e.g., `en-US`)
|
|
||||||
3. **JSON file** in requested locale (e.g., `messages/de.json`)
|
|
||||||
4. **JSON file** in English (e.g., `messages/en.json`)
|
|
||||||
5. **Key itself** as last resort (e.g., returns `"nav.home"`)
|
|
||||||
|
|
||||||
## ✅ What Was Fixed
|
|
||||||
|
|
||||||
Previous issues that have been resolved:
|
|
||||||
|
|
||||||
1. ✅ **Type mismatches** - All translation types now match actual component usage
|
|
||||||
2. ✅ **Unused fields** - Removed translation keys that were never used (like `hero.greeting`, `hero.name`)
|
|
||||||
3. ✅ **Wrong structure** - Fixed `AboutTranslations` structure (removed fake `interests` nesting)
|
|
||||||
4. ✅ **Missing keys** - Aligned loaders with JSON files and actual component requirements
|
|
||||||
5. ✅ **Confusing comments** - Removed misleading comments in `translations-loader.ts`
|
|
||||||
|
|
||||||
## 🎯 Best Practices
|
|
||||||
|
|
||||||
1. **Always maintain JSON files** - Even if using Directus, keep JSON files as fallback
|
|
||||||
2. **Use types** - TypeScript types ensure correct usage
|
|
||||||
3. **Test without Directus** - App should work perfectly without CMS configured
|
|
||||||
4. **Rich text for formatting** - Use `content_pages` for content that needs bold/italic/lists
|
|
||||||
5. **JSON for UI labels** - Use JSON/messages for short UI strings like buttons and labels
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
### Directus not configured
|
|
||||||
**This is normal!** The app works fine. All translations come from JSON files.
|
|
||||||
|
|
||||||
### Want to use Directus?
|
|
||||||
1. Set up `DIRECTUS_URL` and `DIRECTUS_STATIC_TOKEN`
|
|
||||||
2. Create `messages` collection
|
|
||||||
3. Add your translations
|
|
||||||
4. They will override JSON values
|
|
||||||
|
|
||||||
### Translation not showing?
|
|
||||||
Check in this order:
|
|
||||||
1. Does key exist in `messages/en.json`?
|
|
||||||
2. Is the key spelled correctly?
|
|
||||||
3. Is component using correct namespace?
|
|
||||||
|
|
||||||
## 📚 Further Reading
|
|
||||||
|
|
||||||
- **Complete locale documentation**: `docs/LOCALE_SYSTEM.md`
|
|
||||||
- **Directus setup checklist**: `DIRECTUS_CHECKLIST.md`
|
|
||||||
- **Operations guide**: `docs/OPERATIONS.md`
|
|
||||||
|
|
||||||
34
GEMINI.md
Normal file
34
GEMINI.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Gemini CLI: Project Context & Engineering Mandates
|
||||||
|
|
||||||
|
## Project Identity
|
||||||
|
- **Name:** Dennis Konkol Portfolio (dk0.dev)
|
||||||
|
- **Aesthetic:** "Liquid Editorial Bento" (Premium, minimalistisch, hoch-typografisch).
|
||||||
|
- **Core Palette:** Creme (`#fdfcf8`), Stone (`#0c0a09`), Emerald (`#10b981`), Sky, Purple.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Framework:** Next.js 15 (App Router), Tailwind CSS 3.4.
|
||||||
|
- **CMS:** Directus (primär für Texte, Hobbies, Tech-Stack, Projekte).
|
||||||
|
- **Database:** PostgreSQL (Prisma) als lokaler Cache/Mirror für Projekte.
|
||||||
|
- **Animations:** Framer Motion (bevorzugt für alle Übergänge).
|
||||||
|
- **i18n:** `next-intl` (Locales: `en`, `de`).
|
||||||
|
|
||||||
|
## Engineering Guidelines (Mandates)
|
||||||
|
|
||||||
|
### 1. UI Components
|
||||||
|
- **Bento Grid:** Neue Features sollten immer in das bestehende Grid integriert werden. Keine schwebenden Overlays.
|
||||||
|
- **Skeletons:** Jede asynchrone Komponente benötigt einen passenden `Skeleton` Ladezustand.
|
||||||
|
- **Typography:** Headlines immer uppercase, tracking-tighter, mit Akzent-Punkt am Ende.
|
||||||
|
|
||||||
|
### 2. Implementation Rules
|
||||||
|
- **TypeScript:** Keine `any`. Nutze bestehende Interfaces in `lib/directus.ts` oder `app/_ui/`.
|
||||||
|
- **Resilience:** Alle API-Calls müssen Fehler abfangen und sinnvolle Fallbacks (oder Skeletons) anzeigen.
|
||||||
|
- **Next.js Standalone:** Das Projekt nutzt den `standalone` Build-Mode. Docker-Builds müssen immer verifiziert werden.
|
||||||
|
|
||||||
|
### 3. Agent Instructions
|
||||||
|
- **Codebase Investigator:** Nutze dieses Tool für Architektur-Fragen.
|
||||||
|
- **Testing:** Führe `npm run test` nach UI-Änderungen aus. Achte auf JSDOM-Einschränkungen (Mocking von `window.matchMedia` und `IntersectionObserver`).
|
||||||
|
- **CMS First:** Texte sollten nach Möglichkeit aus der `messages` Collection in Directus kommen, nicht hartcodiert werden.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
- **Branch:** `dev` (pushed)
|
||||||
|
- **Status:** Design Overhaul abgeschlossen, Build stabil, Docker verifiziert.
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
# 🚀 Safe Push to Main Branch Guide
|
|
||||||
|
|
||||||
**IMPORTANT**: This guide ensures you don't break production when merging to main.
|
|
||||||
|
|
||||||
## ⚠️ Pre-Flight Checklist
|
|
||||||
|
|
||||||
Before even thinking about pushing to main, verify ALL of these:
|
|
||||||
|
|
||||||
### 1. Code Quality ✅
|
|
||||||
```bash
|
|
||||||
# Run all checks
|
|
||||||
npm run build # Must pass with 0 errors
|
|
||||||
npm run lint # Must pass with 0 errors
|
|
||||||
npx tsc --noEmit # TypeScript must be clean
|
|
||||||
npx prisma format # Database schema must be valid
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1b. Automated Testing ✅
|
|
||||||
```bash
|
|
||||||
# Run comprehensive test suite (RECOMMENDED)
|
|
||||||
npm run test:all # Runs all tests including E2E
|
|
||||||
|
|
||||||
# Or run individually:
|
|
||||||
npm run test # Unit tests
|
|
||||||
npm run test:critical # Critical path E2E tests
|
|
||||||
npm run test:hydration # Hydration tests
|
|
||||||
npm run test:email # Email API tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Testing ✅
|
|
||||||
```bash
|
|
||||||
# Automated testing (RECOMMENDED)
|
|
||||||
npm run test:all # Runs all automated tests
|
|
||||||
|
|
||||||
# Manual testing (if needed)
|
|
||||||
npm run dev
|
|
||||||
# Test these critical paths:
|
|
||||||
# - Home page loads
|
|
||||||
# - Projects page works
|
|
||||||
# - Admin dashboard accessible
|
|
||||||
# - API endpoints respond
|
|
||||||
# - No console errors
|
|
||||||
# - No hydration errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Database Changes ✅
|
|
||||||
```bash
|
|
||||||
# If you changed the database schema:
|
|
||||||
# 1. Create migration
|
|
||||||
npx prisma migrate dev --name your_migration_name
|
|
||||||
|
|
||||||
# 2. Test migration on a copy of production data
|
|
||||||
# 3. Document migration steps
|
|
||||||
# 4. Create rollback plan
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Environment Variables ✅
|
|
||||||
- [ ] All new env vars documented in `env.example`
|
|
||||||
- [ ] No secrets committed to git
|
|
||||||
- [ ] Production env vars are set on server
|
|
||||||
- [ ] Optional features have fallbacks
|
|
||||||
|
|
||||||
### 5. Breaking Changes ✅
|
|
||||||
- [ ] Documented in CHANGELOG
|
|
||||||
- [ ] Backward compatible OR migration plan exists
|
|
||||||
- [ ] Team notified of changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Step-by-Step Push Process
|
|
||||||
|
|
||||||
### Step 1: Ensure You're on Dev Branch
|
|
||||||
```bash
|
|
||||||
git checkout dev
|
|
||||||
git pull origin dev # Get latest changes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Final Verification
|
|
||||||
```bash
|
|
||||||
# Clean build
|
|
||||||
rm -rf .next node_modules/.cache
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Should complete without errors
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Review Your Changes
|
|
||||||
```bash
|
|
||||||
# See what you're about to push
|
|
||||||
git log origin/main..dev --oneline
|
|
||||||
git diff origin/main..dev
|
|
||||||
|
|
||||||
# Review carefully:
|
|
||||||
# - No accidental secrets
|
|
||||||
# - No debug code
|
|
||||||
# - No temporary files
|
|
||||||
# - All changes are intentional
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Create a Backup Branch (Safety Net)
|
|
||||||
```bash
|
|
||||||
# Create backup before merging
|
|
||||||
git checkout -b backup-before-main-merge-$(date +%Y%m%d)
|
|
||||||
git push origin backup-before-main-merge-$(date +%Y%m%d)
|
|
||||||
git checkout dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Merge Dev into Main (Local)
|
|
||||||
```bash
|
|
||||||
# Switch to main
|
|
||||||
git checkout main
|
|
||||||
git pull origin main # Get latest main
|
|
||||||
|
|
||||||
# Merge dev into main
|
|
||||||
git merge dev --no-ff -m "Merge dev into main: [describe changes]"
|
|
||||||
|
|
||||||
# If conflicts occur:
|
|
||||||
# 1. Resolve conflicts carefully
|
|
||||||
# 2. Test after resolving
|
|
||||||
# 3. Don't force push if unsure
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Test the Merged Code
|
|
||||||
```bash
|
|
||||||
# Build and test the merged code
|
|
||||||
npm run build
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Test critical paths again
|
|
||||||
# - Home page
|
|
||||||
# - Projects
|
|
||||||
# - Admin
|
|
||||||
# - APIs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 7: Push to Main (If Everything Looks Good)
|
|
||||||
```bash
|
|
||||||
# Push to remote main
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# If you need to force push (DANGEROUS - only if necessary):
|
|
||||||
# git push origin main --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 8: Monitor Deployment
|
|
||||||
```bash
|
|
||||||
# Watch your deployment logs
|
|
||||||
# Check for errors
|
|
||||||
# Verify health endpoints
|
|
||||||
# Test production site
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Safety Strategies
|
|
||||||
|
|
||||||
### Strategy 1: Feature Flags
|
|
||||||
If you're adding new features, use feature flags:
|
|
||||||
```typescript
|
|
||||||
// In your code
|
|
||||||
if (process.env.ENABLE_NEW_FEATURE === 'true') {
|
|
||||||
// New feature code
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Strategy 2: Gradual Rollout
|
|
||||||
- Deploy to staging first
|
|
||||||
- Test thoroughly
|
|
||||||
- Then deploy to production
|
|
||||||
- Monitor closely
|
|
||||||
|
|
||||||
### Strategy 3: Database Migrations
|
|
||||||
```bash
|
|
||||||
# Always test migrations first
|
|
||||||
# 1. Backup production database
|
|
||||||
# 2. Test migration on copy
|
|
||||||
# 3. Create rollback script
|
|
||||||
# 4. Run migration during low-traffic period
|
|
||||||
```
|
|
||||||
|
|
||||||
### Strategy 4: Rollback Plan
|
|
||||||
Always have a rollback plan:
|
|
||||||
```bash
|
|
||||||
# If something breaks:
|
|
||||||
git revert HEAD
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# Or rollback to previous commit:
|
|
||||||
git reset --hard <previous-commit-hash>
|
|
||||||
git push origin main --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Red Flags - DON'T PUSH IF:
|
|
||||||
|
|
||||||
- ❌ Build fails
|
|
||||||
- ❌ Tests fail
|
|
||||||
- ❌ Linter errors
|
|
||||||
- ❌ TypeScript errors
|
|
||||||
- ❌ Database migration not tested
|
|
||||||
- ❌ Breaking changes not documented
|
|
||||||
- ❌ Secrets in code
|
|
||||||
- ❌ Debug code left in
|
|
||||||
- ❌ Console.logs everywhere
|
|
||||||
- ❌ Untested features
|
|
||||||
- ❌ No rollback plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Green Lights - SAFE TO PUSH IF:
|
|
||||||
|
|
||||||
- ✅ All checks pass
|
|
||||||
- ✅ Tested locally
|
|
||||||
- ✅ Database migrations tested
|
|
||||||
- ✅ No breaking changes (or documented)
|
|
||||||
- ✅ Documentation updated
|
|
||||||
- ✅ Team notified
|
|
||||||
- ✅ Rollback plan exists
|
|
||||||
- ✅ Feature flags for new features
|
|
||||||
- ✅ Environment variables documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Pre-Push Checklist Template
|
|
||||||
|
|
||||||
Copy this and check each item:
|
|
||||||
|
|
||||||
```
|
|
||||||
[ ] npm run build passes
|
|
||||||
[ ] npm run lint passes
|
|
||||||
[ ] npx tsc --noEmit passes
|
|
||||||
[ ] npx prisma format passes
|
|
||||||
[ ] npm run test:all passes (automated tests)
|
|
||||||
[ ] OR manual testing:
|
|
||||||
[ ] Dev server starts without errors
|
|
||||||
[ ] Home page loads correctly
|
|
||||||
[ ] Projects page works
|
|
||||||
[ ] Admin dashboard accessible
|
|
||||||
[ ] API endpoints respond
|
|
||||||
[ ] No console errors
|
|
||||||
[ ] No hydration errors
|
|
||||||
[ ] Database migrations tested (if any)
|
|
||||||
[ ] Environment variables documented
|
|
||||||
[ ] No secrets in code
|
|
||||||
[ ] Breaking changes documented
|
|
||||||
[ ] CHANGELOG updated
|
|
||||||
[ ] Team notified (if needed)
|
|
||||||
[ ] Rollback plan exists
|
|
||||||
[ ] Backup branch created
|
|
||||||
[ ] Changes reviewed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Alternative: Pull Request Workflow
|
|
||||||
|
|
||||||
If you want extra safety, use PR workflow:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Push dev branch
|
|
||||||
git push origin dev
|
|
||||||
|
|
||||||
# 2. Create Pull Request on Git platform
|
|
||||||
# - Review changes
|
|
||||||
# - Get approval
|
|
||||||
# - Run CI/CD checks
|
|
||||||
|
|
||||||
# 3. Merge PR to main (platform handles it)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Emergency Rollback
|
|
||||||
|
|
||||||
If production breaks after push:
|
|
||||||
|
|
||||||
### Quick Rollback
|
|
||||||
```bash
|
|
||||||
# 1. Revert the merge commit
|
|
||||||
git revert -m 1 <merge-commit-hash>
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# 2. Or reset to previous state
|
|
||||||
git reset --hard <previous-commit>
|
|
||||||
git push origin main --force-with-lease
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database Rollback
|
|
||||||
```bash
|
|
||||||
# If you ran migrations, roll them back:
|
|
||||||
npx prisma migrate resolve --rolled-back <migration-name>
|
|
||||||
|
|
||||||
# Or restore from backup
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 Need Help?
|
|
||||||
|
|
||||||
If unsure:
|
|
||||||
1. **Don't push** - better safe than sorry
|
|
||||||
2. Test more thoroughly
|
|
||||||
3. Ask for code review
|
|
||||||
4. Use staging environment first
|
|
||||||
5. Create a PR for review
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Best Practices
|
|
||||||
|
|
||||||
1. **Always test locally first**
|
|
||||||
2. **Use feature flags for new features**
|
|
||||||
3. **Test database migrations on copies**
|
|
||||||
4. **Document everything**
|
|
||||||
5. **Have a rollback plan**
|
|
||||||
6. **Monitor after deployment**
|
|
||||||
7. **Deploy during low-traffic periods**
|
|
||||||
8. **Keep main branch stable**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Remember**: It's better to delay a push than to break production! 🛡️
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# 🔒 Security Improvements
|
|
||||||
|
|
||||||
## Implemented Security Features
|
|
||||||
|
|
||||||
### 1. n8n API Endpoint Protection
|
|
||||||
|
|
||||||
All n8n endpoints are now protected with:
|
|
||||||
- **Authentication**: Admin authentication required for sensitive endpoints (`/api/n8n/generate-image`)
|
|
||||||
- **Rate Limiting**:
|
|
||||||
- `/api/n8n/generate-image`: 10 requests/minute
|
|
||||||
- `/api/n8n/chat`: 20 requests/minute
|
|
||||||
- `/api/n8n/status`: 30 requests/minute
|
|
||||||
|
|
||||||
### 2. Email Obfuscation
|
|
||||||
|
|
||||||
Email addresses can now be obfuscated to prevent automated scraping:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createObfuscatedMailto } from '@/lib/email-obfuscate';
|
|
||||||
import { ObfuscatedEmail } from '@/components/ObfuscatedEmail';
|
|
||||||
|
|
||||||
// React component
|
|
||||||
<ObfuscatedEmail email="contact@dk0.dev">Contact Me</ObfuscatedEmail>
|
|
||||||
|
|
||||||
// HTML string
|
|
||||||
const mailtoLink = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
|
|
||||||
```
|
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
- Emails are base64 encoded in the HTML
|
|
||||||
- JavaScript decodes them on click
|
|
||||||
- Prevents simple regex-based email scrapers
|
|
||||||
- Still functional for real users
|
|
||||||
|
|
||||||
### 3. URL Obfuscation
|
|
||||||
|
|
||||||
Sensitive URLs can be obfuscated:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createObfuscatedLink } from '@/lib/email-obfuscate';
|
|
||||||
|
|
||||||
const link = createObfuscatedLink('https://sensitive-url.com', 'Click Here');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Rate Limiting
|
|
||||||
|
|
||||||
All API endpoints have rate limiting:
|
|
||||||
- Prevents brute force attacks
|
|
||||||
- Protects against DDoS
|
|
||||||
- Configurable per endpoint
|
|
||||||
|
|
||||||
## Code Obfuscation
|
|
||||||
|
|
||||||
**Note**: Full code obfuscation for Next.js is **not recommended** because:
|
|
||||||
|
|
||||||
1. **Next.js already minifies code** in production builds
|
|
||||||
2. **Obfuscation breaks source maps** (harder to debug)
|
|
||||||
3. **Performance impact** (slower execution)
|
|
||||||
4. **Not effective** - determined attackers can still reverse engineer
|
|
||||||
5. **Maintenance burden** - harder to debug issues
|
|
||||||
|
|
||||||
**Better alternatives:**
|
|
||||||
- ✅ Minification (already enabled in Next.js)
|
|
||||||
- ✅ Environment variables for secrets
|
|
||||||
- ✅ Server-side rendering (code not exposed)
|
|
||||||
- ✅ API authentication
|
|
||||||
- ✅ Rate limiting
|
|
||||||
- ✅ Security headers
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### For Email Protection:
|
|
||||||
1. Use obfuscated emails in public HTML
|
|
||||||
2. Use contact forms instead of direct mailto links
|
|
||||||
3. Monitor for spam patterns
|
|
||||||
|
|
||||||
### For API Protection:
|
|
||||||
1. Always require authentication for sensitive endpoints
|
|
||||||
2. Use rate limiting
|
|
||||||
3. Log suspicious activity
|
|
||||||
4. Use HTTPS only
|
|
||||||
5. Validate all inputs
|
|
||||||
|
|
||||||
### For Webhook Protection:
|
|
||||||
1. Use secret tokens (`N8N_SECRET_TOKEN`)
|
|
||||||
2. Verify webhook signatures
|
|
||||||
3. Rate limit webhook endpoints
|
|
||||||
4. Monitor webhook usage
|
|
||||||
|
|
||||||
## Implementation Status
|
|
||||||
|
|
||||||
- ✅ n8n endpoints protected with auth + rate limiting
|
|
||||||
- ✅ Email obfuscation utility created
|
|
||||||
- ✅ URL obfuscation utility created
|
|
||||||
- ✅ Rate limiting on all n8n endpoints
|
|
||||||
- ⚠️ Email obfuscation not yet applied to pages (manual step)
|
|
||||||
- ⚠️ Code obfuscation not implemented (not recommended)
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
To apply email obfuscation to your pages:
|
|
||||||
|
|
||||||
1. Import the utility:
|
|
||||||
```typescript
|
|
||||||
import { ObfuscatedEmail } from '@/lib/email-obfuscate';
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Replace email links:
|
|
||||||
```tsx
|
|
||||||
// Before
|
|
||||||
<a href="mailto:contact@dk0.dev">Contact</a>
|
|
||||||
|
|
||||||
// After
|
|
||||||
<ObfuscatedEmail email="contact@dk0.dev">Contact</ObfuscatedEmail>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. For static HTML, use the string function:
|
|
||||||
```typescript
|
|
||||||
const html = createObfuscatedMailto('contact@dk0.dev', 'Email Me');
|
|
||||||
```
|
|
||||||
42
SESSION_SUMMARY.md
Normal file
42
SESSION_SUMMARY.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Session Summary - February 16, 2026
|
||||||
|
|
||||||
|
## 🛡️ Security & Technical Fixes
|
||||||
|
- **CSP Improvements:** Added `images.unsplash.com`, `*.dk0.dev`, and `localhost` to `img-src` and `connect-src`.
|
||||||
|
- **Worker Support:** Enabled `worker-src 'self' blob:;` for dynamic features.
|
||||||
|
- **Source Map Suppression:** Configured Webpack to ignore 404 errors for `framer-motion` and `LayoutGroupContext` source maps in development.
|
||||||
|
- **Project Filtering:** Unified the projects API to use Directus as the "Single Source of Truth," strictly enforcing the `published` status.
|
||||||
|
|
||||||
|
## 🎨 UI/UX Enhancements (Liquid Editorial Bento)
|
||||||
|
- **Hero Section:**
|
||||||
|
- Stabilized the hero photo (removed floating animation).
|
||||||
|
- Fixed edge-clipping by increasing the border/padding.
|
||||||
|
- Removed redundant social buttons for a cleaner entry.
|
||||||
|
- **Activity Feed:**
|
||||||
|
- Full localization (DE/EN).
|
||||||
|
- Added a rotating cycle of CS-related quotes (Dijkstra, etc.) including CMS quotes.
|
||||||
|
- Redesigned Music UI with Spotify-themed branding (`#1DB954`), improved contrast, and animated frequency bars.
|
||||||
|
- **Contact Area:**
|
||||||
|
- Redesigned into a unified "Connect" Bento box.
|
||||||
|
- High-typography list style for Email, GitHub, LinkedIn, and Location.
|
||||||
|
- **Hobbies:**
|
||||||
|
- Added personalized descriptions reflecting interests like Analog Photography, Astronomy, and Traveling.
|
||||||
|
- Switched to a 4-column layout for better spatial balance.
|
||||||
|
|
||||||
|
## 🚀 New Features
|
||||||
|
- **Snippets System ("The Lab"):**
|
||||||
|
- New Directus collection and API endpoint for technical notes.
|
||||||
|
- Interactive Bento-modals with code syntax highlighting and copy-to-clipboard functionality.
|
||||||
|
- Dedicated `/snippets` overview page.
|
||||||
|
- Implemented "Featured" logic to control visibility on the home page.
|
||||||
|
- **Redesigned 404 Page:**
|
||||||
|
- Completely rebuilt in the Editorial Bento style with clear navigation paths.
|
||||||
|
- **Visual Finish:**
|
||||||
|
- Added a subtle, animated CSS-based Grain/Noise overlay.
|
||||||
|
- Implemented smooth Page Transitions using Framer Motion.
|
||||||
|
|
||||||
|
## 💻 Hardware Setup ("My Gear")
|
||||||
|
- Added a dedicated Bento card showing current dev setup:
|
||||||
|
- MacBook Pro M4 Pro (24GB RAM).
|
||||||
|
- PC: Ryzen 7 3800XT / RTX 3080.
|
||||||
|
- Server: IONOS Cloud & Raspberry Pi 4.
|
||||||
|
- Dual MSI 164Hz Curved Monitors.
|
||||||
28
TODO.md
Normal file
28
TODO.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Portfolio Roadmap
|
||||||
|
|
||||||
|
## Completed ✅
|
||||||
|
|
||||||
|
- [x] **Dark Mode Support**: `next-themes` integration, `ThemeToggle` component, and dark mode styles.
|
||||||
|
- [x] **Performance**: Replaced `<img>` with Next.js `<Image>` for optimization.
|
||||||
|
- [x] **SEO**: Added JSON-LD Structured Data for projects.
|
||||||
|
- [x] **Security**: Rate limiting added to `book-reviews`, `hobbies`, and `tech-stack` APIs.
|
||||||
|
- [x] **Book Reviews**:
|
||||||
|
- `ReadBooks` component updated to handle optional ratings/reviews.
|
||||||
|
- `CurrentlyReading` component verified.
|
||||||
|
- Automation guide created (`docs/N8N_HARDCOVER_GUIDE.md`).
|
||||||
|
- [x] **Testing**: Added tests for `book-reviews`, `hobbies`, `tech-stack`, `CurrentlyReading`, and `ThemeToggle`.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Directus CMS
|
||||||
|
- [ ] **Messages Collection**: Create `messages` collection in Directus for dynamic i18n (currently using `messages/*.json`).
|
||||||
|
- [ ] **Projects Migration**: Finish migrating projects content to Directus (script exists: `scripts/migrate-projects-to-directus.js`).
|
||||||
|
- [ ] **Webhooks**: Configure Directus webhooks for On-Demand ISR Revalidation.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- [ ] **Blog/Articles**: Design and implement the blog section.
|
||||||
|
- [ ] **Project Detail Gallery**: Add a lightbox/gallery for project screenshots.
|
||||||
|
|
||||||
|
### DevOps
|
||||||
|
- [ ] **GitHub Actions**: Migrate CI/CD fully to GitHub Actions (from Gitea).
|
||||||
|
- [ ] **Docker Optimization**: Further reduce image size.
|
||||||
100
app/[locale]/books/page.tsx
Normal file
100
app/[locale]/books/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Star, ArrowLeft } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { Skeleton } from "@/app/components/ui/Skeleton";
|
||||||
|
import { BookReview } from "@/lib/directus";
|
||||||
|
|
||||||
|
export default function BooksPage() {
|
||||||
|
const locale = useLocale();
|
||||||
|
const [reviews, setReviews] = useState<BookReview[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBooks = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/book-reviews?locale=${locale}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.bookReviews) setReviews(data.bookReviews);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Books fetch failed:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchBooks();
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="mb-20">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
|
<span className="font-bold uppercase tracking-widest text-xs">{locale === 'de' ? 'Zurück' : 'Back Home'}</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
|
Library<span className="text-liquid-purple">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||||
|
{locale === "de"
|
||||||
|
? "Bücher, die meine Denkweise verändert und mein Wissen erweitert haben."
|
||||||
|
: "Books that shaped my mindset and expanded my horizons."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
||||||
|
<Skeleton className="aspect-[3/4] rounded-2xl mb-8" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-8 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
reviews?.map((review) => (
|
||||||
|
<div
|
||||||
|
key={review.id}
|
||||||
|
className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
{review.book_image && (
|
||||||
|
<div className="relative aspect-[3/4] rounded-2xl overflow-hidden mb-8 shadow-xl border-4 border-stone-50 dark:border-stone-800">
|
||||||
|
<Image src={review.book_image} alt={review.book_title} fill className="object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="flex justify-between items-start gap-4 mb-4">
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-white leading-tight">{review.book_title}</h3>
|
||||||
|
{review.rating && (
|
||||||
|
<div className="flex items-center gap-1 bg-stone-50 dark:bg-stone-800 px-3 py-1 rounded-full border border-stone-100 dark:border-stone-700">
|
||||||
|
<Star size={12} className="fill-amber-400 text-amber-400" />
|
||||||
|
<span className="text-xs font-black">{review.rating}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 font-bold text-sm mb-6">{review.book_author}</p>
|
||||||
|
{review.review && (
|
||||||
|
<div className="mt-auto pt-6 border-t border-stone-50 dark:border-stone-800">
|
||||||
|
<p className="text-stone-600 dark:text-stone-300 italic font-light leading-relaxed">
|
||||||
|
“{review.review.replace(/<[^>]*>/g, '')}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { setRequestLocale } from "next-intl/server";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import ConsentBanner from "../components/ConsentBanner";
|
import ConsentBanner from "../components/ConsentBanner";
|
||||||
import { getLocalizedMessage } from "@/lib/i18n-loader";
|
|
||||||
|
|
||||||
// Supported locales - must match middleware.ts
|
// Supported locales - must match middleware.ts
|
||||||
const SUPPORTED_LOCALES = ["en", "de"] as const;
|
const SUPPORTED_LOCALES = ["en", "de"] as const;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
import { getProjectBySlug } from "@/lib/directus";
|
||||||
|
import { ProjectDetailData } from "@/app/_ui/ProjectDetailClient";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -12,6 +14,20 @@ export async function generateMetadata({
|
|||||||
params: Promise<{ locale: string; slug: string }>;
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
|
||||||
|
// Try Directus first for metadata
|
||||||
|
const directusProject = await getProjectBySlug(slug, locale);
|
||||||
|
if (directusProject) {
|
||||||
|
return {
|
||||||
|
title: directusProject.title,
|
||||||
|
description: directusProject.description,
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||||
|
languages: getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
||||||
return {
|
return {
|
||||||
alternates: {
|
alternates: {
|
||||||
@@ -28,7 +44,8 @@ export default async function ProjectPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
|
|
||||||
const project = await prisma.project.findFirst({
|
// Try PostgreSQL first
|
||||||
|
const dbProject = await prisma.project.findFirst({
|
||||||
where: { slug, published: true },
|
where: { slug, published: true },
|
||||||
include: {
|
include: {
|
||||||
translations: {
|
translations: {
|
||||||
@@ -37,29 +54,66 @@ export default async function ProjectPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) return notFound();
|
let projectData: ProjectDetailData | null = null;
|
||||||
|
|
||||||
const trPreferred = project.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
if (dbProject) {
|
||||||
const trDefault = project.translations?.find(
|
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||||
(t) => t.locale === project.defaultLocale && (t?.title || t?.description),
|
const trDefault = dbProject.translations?.find(
|
||||||
);
|
(t) => t.locale === dbProject.defaultLocale && (t?.title || t?.description),
|
||||||
const tr = trPreferred ?? trDefault;
|
);
|
||||||
const { translations: _translations, ...rest } = project;
|
const tr = trPreferred ?? trDefault;
|
||||||
const localizedContent = (() => {
|
const { translations: _translations, ...rest } = dbProject;
|
||||||
if (typeof tr?.content === "string") return tr.content;
|
const localizedContent = (() => {
|
||||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
if (typeof tr?.content === "string") return tr.content;
|
||||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||||
if (typeof markdown === "string") return markdown;
|
const markdown = (tr.content as Record<string, unknown>).markdown;
|
||||||
|
if (typeof markdown === "string") return markdown;
|
||||||
|
}
|
||||||
|
return dbProject.content;
|
||||||
|
})();
|
||||||
|
projectData = {
|
||||||
|
...rest,
|
||||||
|
title: tr?.title ?? dbProject.title,
|
||||||
|
description: tr?.description ?? dbProject.description,
|
||||||
|
content: localizedContent,
|
||||||
|
} as ProjectDetailData;
|
||||||
|
} else {
|
||||||
|
// Try Directus fallback
|
||||||
|
const directusProject = await getProjectBySlug(slug, locale);
|
||||||
|
if (directusProject) {
|
||||||
|
projectData = {
|
||||||
|
...directusProject,
|
||||||
|
id: typeof directusProject.id === 'string' ? (parseInt(directusProject.id) || 0) : directusProject.id,
|
||||||
|
} as ProjectDetailData;
|
||||||
}
|
}
|
||||||
return project.content;
|
}
|
||||||
})();
|
|
||||||
const localized = {
|
if (!projectData) return notFound();
|
||||||
...rest,
|
|
||||||
title: tr?.title ?? project.title,
|
const jsonLd = {
|
||||||
description: tr?.description ?? project.description,
|
"@context": "https://schema.org",
|
||||||
content: localizedContent,
|
"@type": "SoftwareSourceCode",
|
||||||
|
"name": projectData.title,
|
||||||
|
"description": projectData.description,
|
||||||
|
"codeRepository": projectData.github_url || projectData.github,
|
||||||
|
"programmingLanguage": projectData.technologies,
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": "Dennis Konkol"
|
||||||
|
},
|
||||||
|
"dateCreated": projectData.date || projectData.created_at,
|
||||||
|
"url": toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||||
|
"image": (projectData.imageUrl || projectData.image_url) ? toAbsoluteUrl((projectData.imageUrl || projectData.image_url)!) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ProjectDetailClient project={localized} locale={locale} />;
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<ProjectDetailClient project={projectData} locale={locale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
import { getProjects as getDirectusProjects } from "@/lib/directus";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ export default async function ProjectsPage({
|
|||||||
}) {
|
}) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
|
||||||
const projects = await prisma.project.findMany({
|
// Fetch from PostgreSQL
|
||||||
|
const dbProjects = await prisma.project.findMany({
|
||||||
where: { published: true },
|
where: { published: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
@@ -37,20 +39,56 @@ export default async function ProjectsPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const localized = projects.map((p) => {
|
// Fetch from Directus
|
||||||
|
let directusProjects: ProjectListItem[] = [];
|
||||||
|
try {
|
||||||
|
const fetched = await getDirectusProjects(locale, { published: true });
|
||||||
|
if (fetched) {
|
||||||
|
directusProjects = fetched.map(p => ({
|
||||||
|
...p,
|
||||||
|
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||||
|
})) as ProjectListItem[];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Directus projects fetch failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localizedDb: ProjectListItem[] = dbProjects.map((p) => {
|
||||||
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
const trPreferred = p.translations?.find((t) => t.locale === locale && (t?.title || t?.description));
|
||||||
const trDefault = p.translations?.find(
|
const trDefault = p.translations?.find(
|
||||||
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
||||||
);
|
);
|
||||||
const tr = trPreferred ?? trDefault;
|
const tr = trPreferred ?? trDefault;
|
||||||
const { translations: _translations, ...rest } = p;
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
id: p.id,
|
||||||
|
slug: p.slug,
|
||||||
title: tr?.title ?? p.title,
|
title: tr?.title ?? p.title,
|
||||||
description: tr?.description ?? p.description,
|
description: tr?.description ?? p.description,
|
||||||
|
tags: p.tags,
|
||||||
|
category: p.category,
|
||||||
|
date: p.date,
|
||||||
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return <ProjectsPageClient projects={localized} locale={locale} />;
|
// Merge projects, prioritizing DB ones if slugs match
|
||||||
|
const allProjects: ProjectListItem[] = [...localizedDb];
|
||||||
|
const dbSlugs = new Set(localizedDb.map(p => p.slug));
|
||||||
|
|
||||||
|
for (const dp of directusProjects) {
|
||||||
|
if (!dbSlugs.has(dp.slug)) {
|
||||||
|
allProjects.push(dp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final sort by date
|
||||||
|
allProjects.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.date || a.createdAt || 0).getTime();
|
||||||
|
const dateB = new Date(b.date || b.createdAt || 0).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return <ProjectsPageClient projects={allProjects} locale={locale} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
109
app/[locale]/snippets/SnippetsClient.tsx
Normal file
109
app/[locale]/snippets/SnippetsClient.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Snippet } from "@/lib/directus";
|
||||||
|
import { X, Copy, Check, Hash } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SnippetsClient({ initialSnippets }: { initialSnippets: Snippet[] }) {
|
||||||
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyToClipboard = (code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
{initialSnippets.map((s, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={s.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: i * 0.05 }}
|
||||||
|
onClick={() => setSelectedSnippet(s)}
|
||||||
|
className="text-left bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm hover:shadow-xl hover:border-liquid-purple/40 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<div className="w-8 h-8 rounded-xl bg-stone-50 dark:bg-stone-800 flex items-center justify-center text-stone-400 group-hover:text-liquid-purple transition-colors">
|
||||||
|
<Hash size={16} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-stone-400">{s.category}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-white uppercase tracking-tighter mb-4 group-hover:text-liquid-purple transition-colors">{s.title}</h3>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm line-clamp-2 leading-relaxed">
|
||||||
|
{s.description}
|
||||||
|
</p>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snippet Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedSnippet && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<div className="p-8 md:p-10 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-start mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
|
||||||
|
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
|
||||||
|
{selectedSnippet.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative group/code">
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||||
|
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||||
|
title="Copy Code"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
||||||
|
<code>{selectedSnippet.code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Close Laboratory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
app/[locale]/snippets/page.tsx
Normal file
41
app/[locale]/snippets/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { getSnippets } from "@/lib/directus";
|
||||||
|
import { Terminal, ArrowLeft } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import SnippetsClient from "./SnippetsClient";
|
||||||
|
|
||||||
|
export default async function SnippetsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
const snippets = await getSnippets(100) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 transition-colors duration-500">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-[0.3em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-all mb-12 group"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
|
Back to Portfolio
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="mb-20">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900">
|
||||||
|
<Terminal size={24} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50">
|
||||||
|
The Lab<span className="text-liquid-purple">.</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-2xl leading-relaxed">
|
||||||
|
A collection of technical snippets, configurations, and mental notes from my daily building process.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<SnippetsClient initialSnippets={snippets} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/__tests__/api/book-reviews.test.tsx
Normal file
20
app/__tests__/api/book-reviews.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/book-reviews/route";
|
||||||
|
|
||||||
|
// Mock the route handler module
|
||||||
|
jest.mock("@/app/api/book-reviews/route", () => ({
|
||||||
|
GET: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GET /api/book-reviews", () => {
|
||||||
|
it("should return book reviews", async () => {
|
||||||
|
(GET as jest.Mock).mockResolvedValue(
|
||||||
|
NextResponse.json({ bookReviews: [{ id: 1, book_title: "Test" }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET({} as NextRequest);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.bookReviews).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
app/__tests__/api/hobbies.test.tsx
Normal file
20
app/__tests__/api/hobbies.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/hobbies/route";
|
||||||
|
|
||||||
|
// Mock the route handler module
|
||||||
|
jest.mock("@/app/api/hobbies/route", () => ({
|
||||||
|
GET: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GET /api/hobbies", () => {
|
||||||
|
it("should return hobbies", async () => {
|
||||||
|
(GET as jest.Mock).mockResolvedValue(
|
||||||
|
NextResponse.json({ hobbies: [{ id: 1, title: "Gaming" }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET({} as NextRequest);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.hobbies).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
app/__tests__/api/tech-stack.test.tsx
Normal file
20
app/__tests__/api/tech-stack.test.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse, NextRequest } from "next/server";
|
||||||
|
import { GET } from "@/app/api/tech-stack/route";
|
||||||
|
|
||||||
|
// Mock the route handler module
|
||||||
|
jest.mock("@/app/api/tech-stack/route", () => ({
|
||||||
|
GET: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("GET /api/tech-stack", () => {
|
||||||
|
it("should return tech stack", async () => {
|
||||||
|
(GET as jest.Mock).mockResolvedValue(
|
||||||
|
NextResponse.json({ techStack: [{ id: 1, name: "Frontend" }] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await GET({} as NextRequest);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.techStack).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,7 +64,8 @@ describe('ActivityFeed NaN Handling', () => {
|
|||||||
|
|
||||||
// In the actual code, we use String(data.gaming.name || '')
|
// In the actual code, we use String(data.gaming.name || '')
|
||||||
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
// If data.gaming.name is NaN, (NaN || '') evaluates to '' because NaN is falsy
|
||||||
const nanName = String(NaN || '');
|
const nanValue = NaN;
|
||||||
|
const nanName = String(nanValue || '');
|
||||||
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
expect(nanName).toBe(''); // NaN is falsy, so it falls back to ''
|
||||||
expect(typeof nanName).toBe('string');
|
expect(typeof nanName).toBe('string');
|
||||||
});
|
});
|
||||||
|
|||||||
51
app/__tests__/components/CurrentlyReading.test.tsx
Normal file
51
app/__tests__/components/CurrentlyReading.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import CurrentlyReadingComp from "@/app/components/CurrentlyReading";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// Mock next-intl completely to avoid ESM issues
|
||||||
|
jest.mock("next-intl", () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
useLocale: () => "en",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/image
|
||||||
|
jest.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} alt={props.alt || ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CurrentlyReading Component", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders skeleton when loading", () => {
|
||||||
|
(global.fetch as jest.Mock).mockReturnValue(new Promise(() => {}));
|
||||||
|
const { container } = render(<CurrentlyReadingComp />);
|
||||||
|
expect(container.querySelector(".animate-pulse")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a book when data is fetched", async () => {
|
||||||
|
const mockBooks = [
|
||||||
|
{
|
||||||
|
title: "Test Book",
|
||||||
|
authors: ["Test Author"],
|
||||||
|
image: "/test.jpg",
|
||||||
|
progress: 50,
|
||||||
|
startedAt: "2024-01-01"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
(global.fetch as jest.Mock).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ currentlyReading: mockBooks }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CurrentlyReadingComp />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Test Book")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Test Author")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,34 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Header from '@/app/components/Header';
|
import Header from '@/app/components/Header';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
// Mock next-intl
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'en',
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
home: 'Home',
|
||||||
|
about: 'About',
|
||||||
|
projects: 'Projects',
|
||||||
|
contact: 'Contact'
|
||||||
|
};
|
||||||
|
return messages[key] || key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
usePathname: () => '/en',
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Header', () => {
|
describe('Header', () => {
|
||||||
it('renders the header', () => {
|
it('renders the header with the dk logo', () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
expect(screen.getByText('dk')).toBeInTheDocument();
|
expect(screen.getByText('dk')).toBeInTheDocument();
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
|
|
||||||
const aboutButtons = screen.getAllByText('About');
|
// Check for navigation links
|
||||||
expect(aboutButtons.length).toBeGreaterThan(0);
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('About')).toBeInTheDocument();
|
||||||
const projectsButtons = screen.getAllByText('Projects');
|
expect(screen.getByText('Projects')).toBeInTheDocument();
|
||||||
expect(projectsButtons.length).toBeGreaterThan(0);
|
expect(screen.getByText('Contact')).toBeInTheDocument();
|
||||||
|
|
||||||
const contactButtons = screen.getAllByText('Contact');
|
|
||||||
expect(contactButtons.length).toBeGreaterThan(0);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
it('renders the mobile header', () => {
|
|
||||||
render(<Header />);
|
|
||||||
// Check for mobile menu button (hamburger icon)
|
|
||||||
const menuButton = screen.getByLabelText('Open menu');
|
|
||||||
expect(menuButton).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import Hero from '@/app/components/Hero';
|
import Hero from '@/app/components/Hero';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
// Mock next-intl
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'en',
|
||||||
|
useTranslations: () => (key: string) => {
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
description: 'Dennis is a student and passionate self-hoster.',
|
||||||
|
ctaWork: 'View My Work'
|
||||||
|
};
|
||||||
|
return messages[key] || key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next/image
|
||||||
|
interface ImageProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
fill?: boolean;
|
||||||
|
priority?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock('next/image', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ src, alt, fill, priority, ...props }: ImageProps) => (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
data-fill={fill?.toString()}
|
||||||
|
data-priority={priority?.toString()}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Hero', () => {
|
describe('Hero', () => {
|
||||||
it('renders the hero section', () => {
|
it('renders the hero section correctly', () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
expect(screen.getByText('Dennis Konkol')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Student and passionate/i)).toBeInTheDocument();
|
// Check for the main headlines (defaults in Hero.tsx)
|
||||||
|
expect(screen.getByText('Building')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Stuff.')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the description from our mock
|
||||||
|
expect(screen.getByText(/Dennis is a student/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the image
|
||||||
expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
|
expect(screen.getByAltText('Dennis Konkol')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for CTA
|
||||||
|
expect(screen.getByText('View My Work')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
18
app/__tests__/components/ThemeToggle.test.tsx
Normal file
18
app/__tests__/components/ThemeToggle.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ThemeToggle } from "@/app/components/ThemeToggle";
|
||||||
|
|
||||||
|
// Mock next-themes
|
||||||
|
jest.mock("next-themes", () => ({
|
||||||
|
useTheme: () => ({
|
||||||
|
theme: "light",
|
||||||
|
setTheme: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ThemeToggle Component", () => {
|
||||||
|
it("renders the theme toggle button", () => {
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
// Initial render should have the button
|
||||||
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import NotFound from '@/app/not-found';
|
import NotFound from '@/app/not-found';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
// Mock next/navigation
|
||||||
|
jest.mock('next/navigation', () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
back: jest.fn(),
|
||||||
|
push: jest.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock next-intl
|
||||||
|
jest.mock('next-intl', () => ({
|
||||||
|
useLocale: () => 'en',
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('NotFound', () => {
|
describe('NotFound', () => {
|
||||||
it('renders the 404 page', () => {
|
it('renders the 404 page with the new design text', () => {
|
||||||
render(<NotFound />);
|
render(<NotFound />);
|
||||||
expect(screen.getByText("Oops! The page you're looking for doesn't exist.")).toBeInTheDocument();
|
expect(screen.getByText(/Page not/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
type ActivityFeedComponent = React.ComponentType<Record<string, never>>;
|
|
||||||
|
|
||||||
export default function ActivityFeedClient() {
|
|
||||||
const [Comp, setComp] = useState<ActivityFeedComponent | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const mod = await import("../components/ActivityFeed");
|
|
||||||
const C = (mod as unknown as { default?: ActivityFeedComponent }).default;
|
|
||||||
if (!cancelled && typeof C === "function") {
|
|
||||||
setComp(() => C);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!Comp) return null;
|
|
||||||
return <Comp />;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -5,9 +5,14 @@ import Projects from "../components/Projects";
|
|||||||
import Contact from "../components/Contact";
|
import Contact from "../components/Contact";
|
||||||
import Footer from "../components/Footer";
|
import Footer from "../components/Footer";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import ActivityFeedClient from "./ActivityFeedClient";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Force scroll to top on mount to prevent starting at lower sections
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Script
|
<Script
|
||||||
@@ -32,7 +37,6 @@ export default function HomePage() {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActivityFeedClient />
|
|
||||||
<Header />
|
<Header />
|
||||||
{/* Spacer to prevent navbar overlap */}
|
{/* Spacer to prevent navbar overlap */}
|
||||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Header from "../components/Header.server";
|
import Header from "../components/Header.server";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import ActivityFeedClient from "./ActivityFeedClient";
|
|
||||||
import {
|
import {
|
||||||
getHeroTranslations,
|
getHeroTranslations,
|
||||||
getAboutTranslations,
|
getAboutTranslations,
|
||||||
@@ -54,7 +53,6 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
|||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ActivityFeedClient />
|
|
||||||
<Header locale={locale} />
|
<Header locale={locale} />
|
||||||
{/* Spacer to prevent navbar overlap */}
|
{/* Spacer to prevent navbar overlap */}
|
||||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
|
||||||
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export type ProjectDetailData = {
|
export type ProjectDetailData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,10 +16,16 @@ export type ProjectDetailData = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
category: string;
|
category: string;
|
||||||
date: string;
|
date?: string;
|
||||||
|
created_at?: string;
|
||||||
github?: string | null;
|
github?: string | null;
|
||||||
|
github_url?: string | null;
|
||||||
live?: string | null;
|
live?: string | null;
|
||||||
|
button_live_label?: string | null;
|
||||||
|
button_github_label?: string | null;
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
|
image_url?: string | null;
|
||||||
|
technologies?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ProjectDetailClient({
|
export default function ProjectDetailClient({
|
||||||
@@ -31,213 +37,140 @@ export default function ProjectDetailClient({
|
|||||||
}) {
|
}) {
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tDetail = useTranslations("projects.detail");
|
const tDetail = useTranslations("projects.detail");
|
||||||
const tShared = useTranslations("projects.shared");
|
const router = useRouter();
|
||||||
|
const [canGoBack, setCanGoBack] = useState(false);
|
||||||
|
|
||||||
// Track page view (non-blocking)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Prüfen, ob wir eine History haben (von Home gekommen)
|
||||||
|
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||||
|
setCanGoBack(true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
navigator.sendBeacon?.(
|
navigator.sendBeacon?.(
|
||||||
"/api/analytics/track",
|
"/api/analytics/track",
|
||||||
new Blob(
|
new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }),
|
||||||
[
|
|
||||||
JSON.stringify({
|
|
||||||
type: "pageview",
|
|
||||||
projectId: project.id.toString(),
|
|
||||||
page: `/${locale}/projects/${project.slug}`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
{ type: "application/json" },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [project.id, project.slug, locale]);
|
}, [project.id, project.slug, locale]);
|
||||||
|
|
||||||
|
const handleBack = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Wenn wir direkt auf die Seite gekommen sind (Deep Link), gehen wir zur Projektliste
|
||||||
|
// Ansonsten nutzen wir den Browser-Back, um an die exakte Stelle der Home oder Liste zurückzukehren
|
||||||
|
if (canGoBack) {
|
||||||
|
router.back();
|
||||||
|
} else {
|
||||||
|
router.push(`/${locale}/projects`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-20 px-6 transition-colors duration-500">
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Navigation */}
|
|
||||||
<motion.div
|
{/* Navigation - Intelligent Back */}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
<button
|
||||||
animate={{ opacity: 1, y: 0 }}
|
onClick={handleBack}
|
||||||
transition={{ duration: 0.6 }}
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-12 group bg-transparent border-none cursor-pointer"
|
||||||
className="mb-8"
|
|
||||||
>
|
>
|
||||||
<Link
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
href={`/${locale}/projects`}
|
<span className="font-bold uppercase tracking-widest text-xs">
|
||||||
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-900 transition-colors group"
|
{tCommon("back")}
|
||||||
>
|
</span>
|
||||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
</button>
|
||||||
<span className="font-medium">{tCommon("backToProjects")}</span>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Header & Meta */}
|
{/* Title Section */}
|
||||||
<motion.div
|
<div className="mb-20">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{project.title}<span className="text-liquid-mint">.</span>
|
||||||
transition={{ duration: 0.8, delay: 0.1 }}
|
</h1>
|
||||||
className="mb-12"
|
<p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
|
||||||
>
|
|
||||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-black font-sans text-stone-900 tracking-tight leading-tight">
|
|
||||||
{project.title}
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-2 shrink-0 pt-2">
|
|
||||||
{project.featured && (
|
|
||||||
<span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
|
|
||||||
{tShared("featured")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="px-4 py-1.5 bg-white border border-stone-200 text-stone-600 text-xs font-medium rounded-full shadow-sm">
|
|
||||||
{project.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xl md:text-2xl text-stone-600 font-light leading-relaxed max-w-3xl mb-8">
|
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-6 text-stone-500 text-sm border-y border-stone-200 py-6">
|
{/* Feature Image Box */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-4 md:p-8 border border-stone-200/60 dark:border-stone-800/60 shadow-sm mb-12 overflow-hidden">
|
||||||
<Calendar size={18} />
|
<div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
|
||||||
<span className="font-mono">
|
{project.imageUrl ? (
|
||||||
{new Date(project.date).toLocaleDateString(locale || undefined, {
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
|
||||||
year: "numeric",
|
) : (
|
||||||
month: "long",
|
<div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
|
||||||
day: "numeric",
|
<span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||||
})}
|
</div>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-stone-300 hidden sm:block"></div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{project.tags.map((tag) => (
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
<span key={tag} className="text-stone-700 font-medium">
|
<div className="lg:col-span-8 space-y-8">
|
||||||
#{tag}
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
|
||||||
</span>
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
))}
|
<ReactMarkdown>{project.content}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Featured Image / Fallback */}
|
<div className="lg:col-span-4 space-y-8">
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
className="mb-16 rounded-2xl overflow-hidden shadow-2xl bg-stone-100 aspect-video relative"
|
|
||||||
>
|
|
||||||
{project.imageUrl ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={project.imageUrl} alt={project.title} className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-200 to-stone-300 flex items-center justify-center">
|
|
||||||
<span className="text-9xl font-serif font-bold text-stone-500/20 select-none">
|
|
||||||
{project.title.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Content & Sidebar Layout */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
|
||||||
{/* Main Content */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
|
||||||
className="lg:col-span-2"
|
|
||||||
>
|
|
||||||
<div className="markdown prose prose-stone max-w-none prose-lg prose-headings:font-bold prose-headings:tracking-tight prose-a:text-stone-900 prose-a:decoration-stone-300 hover:prose-a:decoration-stone-900 prose-img:rounded-xl prose-img:shadow-lg">
|
|
||||||
<ReactMarkdown
|
|
||||||
components={{
|
|
||||||
h1: ({ children }) => (
|
|
||||||
<h1 className="text-3xl font-bold text-stone-900 mt-8 mb-4">{children}</h1>
|
|
||||||
),
|
|
||||||
h2: ({ children }) => (
|
|
||||||
<h2 className="text-2xl font-bold text-stone-900 mt-8 mb-4">{children}</h2>
|
|
||||||
),
|
|
||||||
p: ({ children }) => <p className="text-stone-700 leading-relaxed mb-6">{children}</p>,
|
|
||||||
li: ({ children }) => <li className="text-stone-700">{children}</li>,
|
|
||||||
code: ({ children }) => (
|
|
||||||
<code className="bg-stone-100 text-stone-800 px-1.5 py-0.5 rounded text-sm font-mono font-medium">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
),
|
|
||||||
pre: ({ children }) => (
|
|
||||||
<pre className="bg-stone-900 text-stone-50 p-6 rounded-xl overflow-x-auto my-6 shadow-lg">
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{project.content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Sidebar / Actions */}
|
{/* Quick Links Box - Only show if links exist */}
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
|
||||||
className="lg:col-span-1 space-y-8"
|
|
||||||
>
|
|
||||||
<div className="bg-white/50 backdrop-blur-xl border border-white/60 p-6 rounded-2xl shadow-sm sticky top-32">
|
|
||||||
<h3 className="font-bold text-stone-900 mb-4 flex items-center gap-2">
|
|
||||||
<Share2 size={18} />
|
|
||||||
{tDetail("links")}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{project.live && project.live.trim() && project.live !== "#" ? (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-between w-full px-4 py-3 bg-stone-900 text-stone-50 rounded-xl font-medium hover:bg-stone-800 hover:scale-[1.02] transition-all shadow-md group"
|
|
||||||
>
|
|
||||||
<span>{tDetail("liveDemo")}</span>
|
|
||||||
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-3 bg-stone-100 text-stone-400 rounded-xl font-medium text-sm text-center border border-stone-200 cursor-not-allowed">
|
|
||||||
{tDetail("liveNotAvailable")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.github && project.github.trim() && project.github !== "#" ? (
|
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center justify-between w-full px-4 py-3 bg-white border border-stone-200 text-stone-700 rounded-xl font-medium hover:bg-stone-50 hover:text-stone-900 hover:border-stone-300 transition-all shadow-sm group"
|
|
||||||
>
|
|
||||||
<span>{tDetail("viewSource")}</span>
|
|
||||||
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-stone-100">
|
<div className="bg-stone-900 dark:bg-stone-800 rounded-[3rem] p-10 border border-stone-800 dark:border-stone-700 shadow-2xl text-white">
|
||||||
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">{tDetail("techStack")}</h4>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
|
||||||
{project.tags.map((tag) => (
|
|
||||||
<span
|
<div className="space-y-4">
|
||||||
key={tag}
|
|
||||||
className="px-2.5 py-1 bg-stone-100 text-stone-600 text-xs font-medium rounded-md border border-stone-200"
|
{project.live && project.live !== "#" && (
|
||||||
>
|
|
||||||
{tag}
|
<a href={project.live} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-white text-stone-900 rounded-2xl font-black hover:scale-105 transition-transform group">
|
||||||
</span>
|
|
||||||
))}
|
<span>{project.button_live_label || tDetail("liveDemo")}</span>
|
||||||
</div>
|
|
||||||
|
<ExternalLink size={20} className="group-hover:translate-x-1 transition-transform" />
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.github && project.github !== "#" && (
|
||||||
|
|
||||||
|
<a href={project.github} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between w-full p-5 bg-stone-800 text-white border border-stone-700 rounded-2xl font-black hover:bg-stone-700 transition-colors group">
|
||||||
|
|
||||||
|
<span>{project.button_github_label || tDetail("viewSource")}</span>
|
||||||
|
|
||||||
|
<GithubIcon size={20} className="group-hover:rotate-12 transition-transform" />
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm">
|
||||||
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-stone-400">Stack</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.tags.map((tag) => (
|
||||||
|
<span key={tag} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
|
import { ArrowUpRight, ArrowLeft, Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Skeleton } from "../components/ui/Skeleton";
|
||||||
|
|
||||||
export type ProjectListItem = {
|
export type ProjectListItem = {
|
||||||
id: number;
|
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
|
||||||
tags: string[];
|
tags: string[];
|
||||||
featured: boolean;
|
|
||||||
category: string;
|
category: string;
|
||||||
date: string;
|
date?: string;
|
||||||
github?: string | null;
|
createdAt?: string;
|
||||||
live?: string | null;
|
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,14 +29,15 @@ export default function ProjectsPageClient({
|
|||||||
}) {
|
}) {
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
const tList = useTranslations("projects.list");
|
const tList = useTranslations("projects.list");
|
||||||
const tShared = useTranslations("projects.shared");
|
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mounted, setMounted] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
// Simulate initial load for smoother entrance or handle actual fetch if needed
|
||||||
|
const timer = setTimeout(() => setLoading(false), 800);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
@@ -47,248 +47,111 @@ export default function ProjectsPageClient({
|
|||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
let result = projects;
|
let result = projects;
|
||||||
|
|
||||||
if (selectedCategory !== "all") {
|
if (selectedCategory !== "all") {
|
||||||
result = result.filter((project) => project.category === selectedCategory);
|
result = result.filter((project) => project.category === selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
(project) =>
|
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
|
||||||
project.title.toLowerCase().includes(query) ||
|
|
||||||
project.description.toLowerCase().includes(query) ||
|
|
||||||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [projects, selectedCategory, searchQuery]);
|
}, [projects, selectedCategory, searchQuery]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 pt-40 pb-20 px-6 transition-colors duration-500">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<div className="mb-24">
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
className="mb-12"
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>{tCommon("backToHome")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
{tList("title")}
|
Archive<span className="text-liquid-mint">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
|
<p className="mt-8 text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-2xl leading-snug tracking-tight">
|
||||||
</motion.div>
|
{tList("intro")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Filters & Search */}
|
{/* Filters */}
|
||||||
<motion.div
|
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
className="mb-12 flex flex-col md:flex-row gap-6 justify-between items-start md:items-center"
|
|
||||||
>
|
|
||||||
{/* Categories */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{categories.map((category) => (
|
{categories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={category}
|
key={cat}
|
||||||
onClick={() => setSelectedCategory(category)}
|
onClick={() => setSelectedCategory(cat)}
|
||||||
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
|
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
|
||||||
selectedCategory === category
|
selectedCategory === cat
|
||||||
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md"
|
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
|
||||||
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
|
: "bg-white dark:bg-stone-900 text-stone-500 border border-stone-200 dark:border-stone-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category === "all" ? tList("all") : category}
|
{cat === 'all' ? tList('all') : cat}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative w-full md:w-80">
|
||||||
{/* Search */}
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||||
<div className="relative w-full md:w-64">
|
<input
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
type="text"
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={tList("searchPlaceholder")}
|
placeholder={tList("searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
|
className="w-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 rounded-2xl py-4 pl-12 pr-6 focus:outline-none focus:ring-2 focus:ring-liquid-mint/30 transition-all shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Projects Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{filteredProjects.map((project, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={project.id}
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: index * 0.1 }}
|
|
||||||
whileHover={{ y: -8 }}
|
|
||||||
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-all duration-500"
|
|
||||||
>
|
|
||||||
{/* Image / Fallback / Cover Area */}
|
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
|
|
||||||
{project.imageUrl ? (
|
|
||||||
<>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={project.imageUrl}
|
|
||||||
alt={project.title}
|
|
||||||
className="w-full h-full object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
|
|
||||||
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
|
|
||||||
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
|
|
||||||
|
|
||||||
<div className="relative z-10">
|
|
||||||
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
|
|
||||||
{project.title.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Texture/Grain Overlay */}
|
|
||||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
|
||||||
|
|
||||||
{/* Animated Shine Effect */}
|
|
||||||
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
|
|
||||||
|
|
||||||
{project.featured && (
|
|
||||||
<div className="absolute top-3 left-3 z-20">
|
|
||||||
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
|
|
||||||
{tShared("featured")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay Links */}
|
|
||||||
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="GitHub"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="Live Demo"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 flex flex-col flex-1">
|
|
||||||
{/* Stretched Link covering the whole card (including image area) */}
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/projects/${project.slug}`}
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
aria-label={`View project ${project.title}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
|
|
||||||
<Calendar size={12} />
|
|
||||||
<span>{new Date(project.date).getFullYear()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">{project.description}</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{project.tags.slice(0, 4).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{project.tags.length > 4 && (
|
|
||||||
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes("kernel panic") && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredProjects.length === 0 && (
|
{/* Grid */}
|
||||||
<div className="text-center py-20">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<p className="text-stone-500 text-lg">{tList("noResults")}</p>
|
{loading ? (
|
||||||
<button
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
onClick={() => {
|
<div key={i} className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col h-full">
|
||||||
setSelectedCategory("all");
|
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
|
||||||
setSearchQuery("");
|
<div className="space-y-3">
|
||||||
}}
|
<Skeleton className="h-8 w-1/2" />
|
||||||
className="mt-4 text-stone-800 font-medium hover:underline"
|
<Skeleton className="h-4 w-3/4" />
|
||||||
>
|
</div>
|
||||||
{tList("clearFilters")}
|
</div>
|
||||||
</button>
|
))
|
||||||
</div>
|
) : (
|
||||||
)}
|
filteredProjects.map((project) => (
|
||||||
|
<motion.div key={project.id} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }}>
|
||||||
|
<Link href={`/${locale}/projects/${project.slug}`} className="group block h-full">
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm h-full hover:shadow-xl transition-all flex flex-col">
|
||||||
|
{project.imageUrl && (
|
||||||
|
<div className="relative aspect-[16/10] rounded-[2rem] overflow-hidden mb-8 border-4 border-stone-50 dark:border-stone-800 shadow-lg">
|
||||||
|
<Image src={project.imageUrl} alt={project.title} fill className="object-cover group-hover:scale-105 transition-transform duration-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tight">{project.title}</h3>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-stone-50 dark:bg-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all">
|
||||||
|
<ArrowUpRight size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 font-light text-lg mb-8 line-clamp-3 leading-relaxed">{project.description}</p>
|
||||||
|
<div className="mt-auto flex flex-wrap gap-2">
|
||||||
|
{project.tags.slice(0, 3).map(tag => (
|
||||||
|
<span key={tag} className="px-3 py-1 bg-stone-50 dark:bg-stone-800 rounded-lg text-[9px] font-black uppercase tracking-widest text-stone-400">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
app/api/book-reviews/route.ts
Normal file
56
app/api/book-reviews/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getBookReviews } from '@/lib/directus';
|
||||||
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/book-reviews
|
||||||
|
*
|
||||||
|
* Loads Book Reviews from Directus CMS
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - locale: en or de (default: en)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate Limit: 60 requests per minute
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 60, 60000)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
|
const reviews = await getBookReviews(locale);
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log(`[API] Book Reviews geladen für ${locale}:`, reviews?.length || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviews && reviews.length > 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
bookReviews: reviews,
|
||||||
|
source: 'directus'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
bookReviews: null,
|
||||||
|
source: 'fallback'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading book reviews:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
bookReviews: null,
|
||||||
|
error: 'Failed to load book reviews',
|
||||||
|
source: 'error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getHobbies } from '@/lib/directus';
|
import { getHobbies } from '@/lib/directus';
|
||||||
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
|
|||||||
* - locale: en or de (default: en)
|
* - locale: en or de (default: en)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate Limit: 60 requests per minute
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 60, 60000)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const locale = searchParams.get('locale') || 'en';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Flatten das Objekt zu flachen Keys
|
// Flatten das Objekt zu flachen Keys
|
||||||
const flatKeys = flattenObject(namespaceData);
|
const flatKeys = flattenObject(namespaceData as Record<string, unknown>);
|
||||||
|
|
||||||
// Lade jeden Key aus Directus (mit Fallback auf JSON)
|
// Lade jeden Key aus Directus (mit Fallback auf JSON)
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
@@ -57,19 +57,24 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Holt verschachtelte Werte aus Objekt
|
// Helper: Holt verschachtelte Werte aus Objekt
|
||||||
function getNestedValue(obj: any, path: string): any {
|
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
return path.split('.').reduce<unknown>((current, key) => {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
return (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Flatten verschachteltes Objekt zu flachen Keys
|
// Helper: Flatten verschachteltes Objekt zu flachen Keys
|
||||||
function flattenObject(obj: any, prefix = ''): Record<string, string> {
|
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
Object.assign(result, flattenObject(value, newKey));
|
Object.assign(result, flattenObject(value as Record<string, unknown>, newKey));
|
||||||
} else {
|
} else {
|
||||||
result[newKey] = String(value);
|
result[newKey] = String(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,14 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
import { getMessages } from "@/lib/directus";
|
||||||
import enMessages from '@/messages/en.json';
|
|
||||||
import deMessages from '@/messages/de.json';
|
|
||||||
|
|
||||||
// Cache für 5 Minuten
|
export async function GET(request: NextRequest) {
|
||||||
export const revalidate = 300;
|
const { searchParams } = new URL(request.url);
|
||||||
|
const locale = searchParams.get("locale") || "en";
|
||||||
const messagesMap = { en: enMessages, de: deMessages };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/messages?locale=en
|
|
||||||
* Lädt ALLE Messages aus Directus + JSON Fallback
|
|
||||||
* Wird von next-intl als messages source verwendet
|
|
||||||
*/
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const locale = req.nextUrl.searchParams.get('locale') || 'en';
|
|
||||||
|
|
||||||
// Normalize locale (de-DE -> de)
|
|
||||||
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Starte mit JSON als Basis
|
const messages = await getMessages(locale);
|
||||||
const jsonMessages = messagesMap[normalizedLocale as 'en' | 'de'];
|
return NextResponse.json({ messages });
|
||||||
|
} catch {
|
||||||
// Clone das Objekt
|
return NextResponse.json({ messages: {} }, { status: 500 });
|
||||||
const messages = JSON.parse(JSON.stringify(jsonMessages));
|
|
||||||
|
|
||||||
// Flatten alle Keys
|
|
||||||
const allKeys = getAllKeys(messages);
|
|
||||||
|
|
||||||
// Lade jeden Key aus Directus (überschreibt JSON wenn vorhanden)
|
|
||||||
await Promise.all(
|
|
||||||
allKeys.map(async (key) => {
|
|
||||||
try {
|
|
||||||
const value = await getLocalizedMessage(key, locale);
|
|
||||||
if (value && value !== key) {
|
|
||||||
// Überschreibe den Wert im messages Objekt
|
|
||||||
setNestedValue(messages, key, value);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Fallback auf JSON Wert (schon vorhanden)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json(messages, {
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Messages API error:', error);
|
|
||||||
// Fallback: Return nur JSON messages
|
|
||||||
return NextResponse.json(messagesMap[normalizedLocale as 'en' | 'de'], {
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'public, s-maxage=60',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: Sammle alle Keys aus verschachteltem Objekt
|
|
||||||
function getAllKeys(obj: any, prefix = ''): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
|
||||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
||||||
|
|
||||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
||||||
keys.push(...getAllKeys(value, fullKey));
|
|
||||||
} else {
|
|
||||||
keys.push(fullKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Setze Wert in verschachteltem Objekt
|
|
||||||
function setNestedValue(obj: any, path: string, value: any) {
|
|
||||||
const keys = path.split('.');
|
|
||||||
const lastKey = keys.pop()!;
|
|
||||||
|
|
||||||
let current = obj;
|
|
||||||
for (const key of keys) {
|
|
||||||
if (!(key in current)) {
|
|
||||||
current[key] = {};
|
|
||||||
}
|
|
||||||
current = current[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
current[lastKey] = value;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireSessionAuth, checkRateLimit, getRateLimitHeaders, getClientIp }
|
|||||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||||
import { generateUniqueSlug } from '@/lib/slug';
|
import { generateUniqueSlug } from '@/lib/slug';
|
||||||
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
||||||
|
import { ProjectListItem } from '@/app/_ui/ProjectsPageClient';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -41,87 +42,80 @@ export async function GET(request: NextRequest) {
|
|||||||
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
||||||
const category = searchParams.get('category');
|
const category = searchParams.get('category');
|
||||||
const featured = searchParams.get('featured');
|
const featured = searchParams.get('featured');
|
||||||
const published = searchParams.get('published');
|
const published = searchParams.get('published') === 'false' ? false : true; // Default to true if not specified
|
||||||
const difficulty = searchParams.get('difficulty');
|
const difficulty = searchParams.get('difficulty');
|
||||||
const search = searchParams.get('search');
|
const search = searchParams.get('search');
|
||||||
const locale = searchParams.get('locale') || 'en';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
// Try Directus FIRST (Primary Source)
|
// Try Directus FIRST (Primary Source)
|
||||||
|
let directusProjects: ProjectListItem[] = [];
|
||||||
|
let directusSuccess = false;
|
||||||
try {
|
try {
|
||||||
const directusProjects = await getDirectusProjects(locale, {
|
const fetched = await getDirectusProjects(locale, {
|
||||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
published: published,
|
||||||
category: category || undefined,
|
category: category || undefined,
|
||||||
difficulty: difficulty || undefined,
|
difficulty: difficulty || undefined,
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
limit
|
limit
|
||||||
});
|
});
|
||||||
|
|
||||||
if (directusProjects && directusProjects.length > 0) {
|
if (fetched) {
|
||||||
return NextResponse.json({
|
directusProjects = fetched.map(p => ({
|
||||||
projects: directusProjects,
|
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||||
total: directusProjects.length,
|
slug: p.slug,
|
||||||
page: 1,
|
title: p.title,
|
||||||
limit: directusProjects.length,
|
description: p.description,
|
||||||
source: 'directus'
|
tags: p.tags || [],
|
||||||
});
|
category: p.category || '',
|
||||||
|
date: p.created_at,
|
||||||
|
createdAt: p.created_at,
|
||||||
|
imageUrl: p.image_url,
|
||||||
|
}));
|
||||||
|
directusSuccess = true;
|
||||||
}
|
}
|
||||||
} catch (directusError) {
|
} catch {
|
||||||
console.log('Directus not available, trying PostgreSQL fallback');
|
console.log('Directus error, continuing with PostgreSQL fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback 1: Try PostgreSQL
|
// If Directus returned projects, use them EXCLUSIVELY to avoid showing un-synced local data
|
||||||
try {
|
if (directusSuccess && directusProjects.length > 0) {
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
|
||||||
} catch (dbError) {
|
|
||||||
console.log('PostgreSQL also not available, using empty fallback');
|
|
||||||
|
|
||||||
// Fallback 2: Return empty (components should have hardcoded fallback)
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
projects: [],
|
projects: directusProjects,
|
||||||
total: 0,
|
total: directusProjects.length,
|
||||||
page: 1,
|
source: 'directus'
|
||||||
limit,
|
|
||||||
source: 'fallback'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cache parameters object
|
// Fallback 1: Try PostgreSQL only if Directus failed or is empty
|
||||||
const cacheParams = {
|
try {
|
||||||
page: page.toString(),
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
limit: limit.toString(),
|
} catch {
|
||||||
category,
|
console.log('PostgreSQL not available');
|
||||||
featured,
|
return NextResponse.json({
|
||||||
published,
|
projects: directusProjects, // Might be empty
|
||||||
difficulty,
|
total: directusProjects.length,
|
||||||
search
|
source: 'directus-empty'
|
||||||
};
|
});
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
const cached = await apiCache.getProjects(cacheParams);
|
|
||||||
if (cached && !search) { // Don't cache search results
|
|
||||||
return NextResponse.json(cached);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (category) where.category = category;
|
if (category) where.category = category;
|
||||||
if (featured !== null) where.featured = featured === 'true';
|
if (featured !== null) where.featured = featured === 'true';
|
||||||
if (published !== null) where.published = published === 'true';
|
where.published = published;
|
||||||
if (difficulty) where.difficulty = difficulty;
|
if (difficulty) where.difficulty = difficulty;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
{ tags: { hasSome: [search] } },
|
{ tags: { hasSome: [search] } }
|
||||||
{ content: { contains: search, mode: 'insensitive' } }
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projects, total] = await Promise.all([
|
const [dbProjects, total] = await Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -131,20 +125,31 @@ export async function GET(request: NextRequest) {
|
|||||||
prisma.project.count({ where })
|
prisma.project.count({ where })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = {
|
// Merge logic
|
||||||
projects,
|
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
||||||
total,
|
const mergedProjects: ProjectListItem[] = dbProjects.map(p => ({
|
||||||
pages: Math.ceil(total / limit),
|
id: p.id,
|
||||||
currentPage: page,
|
slug: p.slug,
|
||||||
source: 'postgresql'
|
title: p.title,
|
||||||
};
|
description: p.description,
|
||||||
|
tags: p.tags,
|
||||||
// Cache the result (only for non-search queries)
|
category: p.category,
|
||||||
if (!search) {
|
date: p.date,
|
||||||
await apiCache.setProjects(cacheParams, result);
|
createdAt: p.createdAt.toISOString(),
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const dp of directusProjects) {
|
||||||
|
if (!dbSlugs.has(dp.slug)) {
|
||||||
|
mergedProjects.push(dp);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json({
|
||||||
|
projects: mergedProjects,
|
||||||
|
total: total + (mergedProjects.length - dbProjects.length),
|
||||||
|
source: 'merged'
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle missing database table gracefully
|
// Handle missing database table gracefully
|
||||||
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
if (error instanceof PrismaClientKnownRequestError && error.code === 'P2021') {
|
||||||
|
|||||||
18
app/api/snippets/route.ts
Normal file
18
app/api/snippets/route.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSnippets } from '@/lib/directus';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
|
const featured = searchParams.get('featured') === 'true' ? true : undefined;
|
||||||
|
|
||||||
|
const snippets = await getSnippets(limit, featured);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
snippets: snippets || []
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch snippets' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getTechStack } from '@/lib/directus';
|
import { getTechStack } from '@/lib/directus';
|
||||||
|
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
|
|||||||
* - locale: en or de (default: en)
|
* - locale: en or de (default: en)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
// Rate Limit: 60 requests per minute
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
if (!checkRateLimit(ip, 60, 60000)) {
|
||||||
|
return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const locale = searchParams.get('locale') || 'en';
|
const locale = searchParams.get('locale') || 'en';
|
||||||
|
|||||||
@@ -1,397 +1,389 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, Variants } from "framer-motion";
|
import { useState, useEffect } from "react";
|
||||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
|
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import type { JSONContent } from "@tiptap/react";
|
||||||
import RichTextClient from "./RichTextClient";
|
import RichTextClient from "./RichTextClient";
|
||||||
import CurrentlyReading from "./CurrentlyReading";
|
import CurrentlyReading from "./CurrentlyReading";
|
||||||
|
import ReadBooks from "./ReadBooks";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { TechStackCategory, TechStackItem, Hobby, Snippet } from "@/lib/directus";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ActivityFeed from "./ActivityFeed";
|
||||||
|
import BentoChat from "./BentoChat";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
|
import { LucideIcon, X, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
// Type definitions for CMS data
|
const iconMap: Record<string, LucideIcon> = {
|
||||||
interface TechStackItem {
|
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
||||||
id: string;
|
|
||||||
name: string | number | null | undefined;
|
|
||||||
url?: string;
|
|
||||||
icon_url?: string;
|
|
||||||
sort: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TechStackCategory {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
icon: string;
|
|
||||||
sort: number;
|
|
||||||
name: string;
|
|
||||||
items: TechStackItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Hobby {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
icon: string;
|
|
||||||
title: string | number | null | undefined;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const staggerContainer: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.15,
|
|
||||||
delayChildren: 0.2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeInUp: Variants = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const About = () => {
|
const About = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("home.about");
|
const t = useTranslations("home.about");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||||
const [techStackFromCMS, setTechStackFromCMS] = useState<TechStackCategory[] | null>(null);
|
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||||
const [hobbiesFromCMS, setHobbiesFromCMS] = useState<Hobby[] | null>(null);
|
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||||
|
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||||
|
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const [cmsRes, techRes, hobbiesRes, msgRes, booksRes, snippetsRes] = await Promise.all([
|
||||||
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`,
|
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
||||||
);
|
fetch(`/api/tech-stack?locale=${locale}`),
|
||||||
const data = await res.json();
|
fetch(`/api/hobbies?locale=${locale}`),
|
||||||
// Only use CMS content if it exists for the active locale.
|
fetch(`/api/messages?locale=${locale}`),
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
fetch(`/api/book-reviews?locale=${locale}`),
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
fetch(`/api/snippets?limit=3&featured=true`)
|
||||||
} else {
|
]);
|
||||||
setCmsDoc(null);
|
|
||||||
}
|
const cmsData = await cmsRes.json();
|
||||||
} catch {
|
if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent);
|
||||||
// ignore; fallback to static
|
|
||||||
setCmsDoc(null);
|
const techData = await techRes.json();
|
||||||
}
|
if (techData?.techStack) setTechStack(techData.techStack);
|
||||||
})();
|
|
||||||
}, [locale]);
|
const hobbiesData = await hobbiesRes.json();
|
||||||
|
if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies);
|
||||||
|
|
||||||
// Load Tech Stack from Directus
|
const msgData = await msgRes.json();
|
||||||
useEffect(() => {
|
if (msgData?.messages) setCmsMessages(msgData.messages);
|
||||||
(async () => {
|
|
||||||
try {
|
const snippetsData = await snippetsRes.json();
|
||||||
const res = await fetch(`/api/tech-stack?locale=${encodeURIComponent(locale)}`);
|
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
await booksRes.json();
|
||||||
if (data?.techStack && data.techStack.length > 0) {
|
// Books data is available but we don't need to track count anymore
|
||||||
setTechStackFromCMS(data.techStack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
console.error("About data fetch failed:", error);
|
||||||
console.log('Tech Stack from Directus not available, using fallback');
|
} finally {
|
||||||
}
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
fetchData();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
// Load Hobbies from Directus
|
const copyToClipboard = (code: string) => {
|
||||||
useEffect(() => {
|
navigator.clipboard.writeText(code);
|
||||||
(async () => {
|
setCopied(true);
|
||||||
try {
|
setTimeout(() => setCopied(false), 2000);
|
||||||
const res = await fetch(`/api/hobbies?locale=${encodeURIComponent(locale)}`);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
if (data?.hobbies && data.hobbies.length > 0) {
|
|
||||||
setHobbiesFromCMS(data.hobbies);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('Hobbies from Directus not available, using fallback');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [locale]);
|
|
||||||
|
|
||||||
// Fallback Tech Stack (from messages/en.json, messages/de.json)
|
|
||||||
const techStackFallback = [
|
|
||||||
{
|
|
||||||
key: 'frontend',
|
|
||||||
category: t("techStack.categories.frontendMobile"),
|
|
||||||
icon: Globe,
|
|
||||||
items: ["Next.js", "Tailwind CSS", "Flutter"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'backend',
|
|
||||||
category: t("techStack.categories.backendDevops"),
|
|
||||||
icon: Server,
|
|
||||||
items: ["Docker Swarm", "Traefik", "Nginx Proxy Manager", "Redis"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tools',
|
|
||||||
category: t("techStack.categories.toolsAutomation"),
|
|
||||||
icon: Wrench,
|
|
||||||
items: ["Git", "CI/CD", "n8n", t("techStack.items.selfHostedServices")],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'security',
|
|
||||||
category: t("techStack.categories.securityAdmin"),
|
|
||||||
icon: Shield,
|
|
||||||
items: ["CrowdSec", "Suricata", "Mailcow"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map icon names from Directus to Lucide components
|
|
||||||
const iconMap: Record<string, any> = {
|
|
||||||
Globe,
|
|
||||||
Server,
|
|
||||||
Code,
|
|
||||||
Wrench,
|
|
||||||
Shield,
|
|
||||||
Activity,
|
|
||||||
Lightbulb,
|
|
||||||
Gamepad2
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fallback Hobbies
|
|
||||||
const hobbiesFallback: Array<{ icon: typeof Code; text: string }> = [
|
|
||||||
{ icon: Code, text: t("hobbies.selfHosting") },
|
|
||||||
{ icon: Gamepad2, text: t("hobbies.gaming") },
|
|
||||||
{ icon: Server, text: t("hobbies.gameServers") },
|
|
||||||
{ icon: Activity, text: t("hobbies.jogging") },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Use CMS Hobbies if available, otherwise fallback
|
|
||||||
const hobbies = hobbiesFromCMS
|
|
||||||
? hobbiesFromCMS
|
|
||||||
.map((hobby: Hobby) => {
|
|
||||||
// Convert to string, handling NaN/null/undefined
|
|
||||||
const text = hobby.title == null || (typeof hobby.title === 'number' && isNaN(hobby.title))
|
|
||||||
? ''
|
|
||||||
: String(hobby.title);
|
|
||||||
return {
|
|
||||||
icon: iconMap[hobby.icon] || Code,
|
|
||||||
text
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(h => {
|
|
||||||
const isValid = h.text.trim().length > 0;
|
|
||||||
if (!isValid && process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[About] Filtered out invalid hobby:', h);
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
})
|
|
||||||
: hobbiesFallback;
|
|
||||||
|
|
||||||
// Use CMS Tech Stack if available, otherwise fallback
|
|
||||||
const techStack = techStackFromCMS
|
|
||||||
? techStackFromCMS.map((cat: TechStackCategory) => {
|
|
||||||
const items = cat.items
|
|
||||||
.map((item: TechStackItem) => {
|
|
||||||
// Convert to string, handling NaN/null/undefined
|
|
||||||
if (item.name == null || (typeof item.name === 'number' && isNaN(item.name))) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[About] Invalid item.name in category', cat.key, ':', item);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return String(item.name);
|
|
||||||
})
|
|
||||||
.filter(name => {
|
|
||||||
const isValid = name.trim().length > 0;
|
|
||||||
if (!isValid && process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('[About] Filtered out empty item name in category', cat.key);
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length === 0 && process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('[About] Category has no valid items after filtering:', cat.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: cat.key,
|
|
||||||
category: cat.name,
|
|
||||||
icon: iconMap[cat.icon] || Code,
|
|
||||||
items
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: techStackFallback;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="about" className="py-32 px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
id="about"
|
<div className="max-w-7xl mx-auto">
|
||||||
className="py-24 px-4 bg-gradient-to-br from-liquid-sky/15 via-liquid-lavender/10 to-liquid-pink/15 relative overflow-hidden"
|
|
||||||
>
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
|
||||||
<div className="max-w-6xl mx-auto relative z-10">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
|
{/* 1. Large Bio Text */}
|
||||||
{/* Text Content */}
|
<motion.div
|
||||||
<motion.div
|
initial={{ opacity: 0, y: 30 }}
|
||||||
initial="hidden"
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
whileInView="visible"
|
viewport={{ once: true }}
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
className="md: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"
|
||||||
variants={staggerContainer}
|
|
||||||
className="space-y-8"
|
|
||||||
>
|
>
|
||||||
<motion.h2
|
<div className="space-y-8">
|
||||||
variants={fadeInUp}
|
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
||||||
className="text-4xl md:text-5xl font-bold text-stone-900"
|
{t("title")}<span className="text-liquid-mint">.</span>
|
||||||
>
|
</h2>
|
||||||
{t("title")}
|
<div className="prose prose-stone dark:prose-invert max-w-none text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
</motion.h2>
|
{isLoading ? (
|
||||||
<motion.div
|
<div className="space-y-3">
|
||||||
variants={fadeInUp}
|
<Skeleton className="h-6 w-full" />
|
||||||
className="prose prose-stone prose-lg text-stone-700 space-y-4"
|
<Skeleton className="h-6 w-[95%]" />
|
||||||
>
|
<Skeleton className="h-6 w-[90%]" />
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p>{t("p1")}</p>
|
|
||||||
<p>{t("p2")}</p>
|
|
||||||
<p>{t("p3")}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<motion.div
|
|
||||||
variants={fadeInUp}
|
|
||||||
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-lavender/15 border-2 border-liquid-mint/30 rounded-xl p-5 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Lightbulb size={20} className="text-stone-600 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold text-stone-800 mb-1">
|
|
||||||
{t("funFactTitle")}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-stone-700 leading-relaxed">
|
|
||||||
{t("funFactBody")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : cmsDoc ? (
|
||||||
|
<RichTextClient doc={cmsDoc} />
|
||||||
|
) : (
|
||||||
|
<p>{t("p1")} {t("p2")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-8">
|
||||||
|
<div className="inline-block bg-stone-50 dark:bg-stone-800 px-8 py-4 rounded-3xl border border-stone-100 dark:border-stone-700">
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-mint mb-2">{t("funFactTitle")}</p>
|
||||||
|
{isLoading ? <Skeleton className="h-5 w-48" /> : <p className="text-base font-bold opacity-90">{t("funFactBody")}</p>}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Tech Stack & Hobbies */}
|
{/* 2. Activity / Status Box */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial="hidden"
|
initial={{ opacity: 0, y: 30 }}
|
||||||
whileInView="visible"
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-100px" }}
|
viewport={{ once: true }}
|
||||||
variants={staggerContainer}
|
transition={{ delay: 0.1 }}
|
||||||
className="space-y-8"
|
className="md:col-span-4 bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="relative z-10 h-full">
|
||||||
<motion.h3
|
<h3 className="text-xl font-black mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
||||||
variants={fadeInUp}
|
<Activity size={20} /> Status
|
||||||
className="text-2xl font-bold text-stone-900 mb-6"
|
</h3>
|
||||||
>
|
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} />
|
||||||
{t("techStackTitle")}
|
</div>
|
||||||
</motion.h3>
|
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
||||||
<div className="grid grid-cols-1 gap-4">
|
</motion.div>
|
||||||
{techStack.map((stack, idx) => (
|
|
||||||
<motion.div
|
{/* 3. AI Chat Box */}
|
||||||
key={`${stack.category}-${idx}`}
|
<motion.div
|
||||||
variants={fadeInUp}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
whileHover={{
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
scale: 1.02,
|
viewport={{ once: true }}
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
transition={{ delay: 0.2 }}
|
||||||
}}
|
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
|
||||||
className={`p-5 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${
|
>
|
||||||
idx === 0
|
<div className="flex items-center gap-2 mb-8">
|
||||||
? "bg-gradient-to-br from-liquid-sky/25 to-liquid-mint/25 border-liquid-sky/50 hover:border-liquid-sky/70 hover:from-liquid-sky/35 hover:to-liquid-mint/35"
|
<MessageSquare className="text-liquid-purple" size={24} />
|
||||||
: idx === 1
|
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
|
||||||
? "bg-gradient-to-br from-liquid-peach/25 to-liquid-coral/25 border-liquid-peach/50 hover:border-liquid-peach/70 hover:from-liquid-peach/35 hover:to-liquid-coral/35"
|
</div>
|
||||||
: idx === 2
|
<div className="flex-1">
|
||||||
? "bg-gradient-to-br from-liquid-lavender/25 to-liquid-pink/25 border-liquid-lavender/50 hover:border-liquid-lavender/70 hover:from-liquid-lavender/35 hover:to-liquid-pink/35"
|
<BentoChat />
|
||||||
: "bg-gradient-to-br from-liquid-teal/25 to-liquid-lime/25 border-liquid-teal/50 hover:border-liquid-teal/70 hover:from-liquid-teal/35 hover:to-liquid-lime/35"
|
</div>
|
||||||
}`}
|
</motion.div>
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
{/* 4. Tech Stack */}
|
||||||
<div className="p-2 bg-white rounded-lg shadow-sm text-stone-700">
|
<motion.div
|
||||||
<stack.icon size={18} />
|
initial={{ opacity: 0, y: 30 }}
|
||||||
</div>
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
<h4 className="font-semibold text-stone-800">
|
viewport={{ once: true }}
|
||||||
{stack.category}
|
transition={{ delay: 0.3 }}
|
||||||
</h4>
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
</div>
|
>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-12">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-6">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stack.items.map((item, itemIdx) => (
|
<Skeleton className="h-8 w-24 rounded-xl" />
|
||||||
<span
|
<Skeleton className="h-8 w-16 rounded-xl" />
|
||||||
key={`${stack.category}-${item}-${itemIdx}`}
|
<Skeleton className="h-8 w-20 rounded-xl" />
|
||||||
className={`px-3 py-1.5 rounded-lg border-2 text-sm text-stone-800 font-semibold transition-all duration-400 ease-out backdrop-blur-sm ${
|
</div>
|
||||||
itemIdx % 4 === 0
|
</div>
|
||||||
? "bg-liquid-mint/25 border-liquid-mint/50 hover:bg-liquid-mint/35 hover:border-liquid-mint/70"
|
))
|
||||||
: itemIdx % 4 === 1
|
) : (
|
||||||
? "bg-liquid-lavender/25 border-liquid-lavender/50 hover:bg-liquid-lavender/35 hover:border-liquid-lavender/70"
|
techStack.map((cat) => (
|
||||||
: itemIdx % 4 === 2
|
<div key={cat.id} className="space-y-6">
|
||||||
? "bg-liquid-rose/25 border-liquid-rose/50 hover:bg-liquid-rose/35 hover:border-liquid-rose/70"
|
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">{cat.name}</h4>
|
||||||
: "bg-liquid-sky/25 border-liquid-sky/50 hover:bg-liquid-sky/35 hover:border-liquid-sky/70"
|
<div className="flex flex-wrap gap-2">
|
||||||
}`}
|
{cat.items?.map((item: TechStackItem) => (
|
||||||
>
|
<span key={item.id} className="px-4 py-2 bg-stone-50 dark:bg-stone-800 rounded-xl text-xs font-bold border border-stone-100 dark:border-stone-700/50 hover:border-liquid-mint transition-colors">
|
||||||
{String(item)}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hobbies */}
|
|
||||||
<div>
|
|
||||||
<motion.h3
|
|
||||||
variants={fadeInUp}
|
|
||||||
className="text-xl font-bold text-stone-900 mb-4"
|
|
||||||
>
|
|
||||||
{t("hobbiesTitle")}
|
|
||||||
</motion.h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{hobbies.map((hobby, idx) => (
|
|
||||||
<motion.div
|
|
||||||
key={`hobby-${hobby.text}-${idx}`}
|
|
||||||
variants={fadeInUp}
|
|
||||||
whileHover={{
|
|
||||||
x: 8,
|
|
||||||
scale: 1.02,
|
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${
|
|
||||||
idx === 0
|
|
||||||
? "bg-gradient-to-r from-liquid-mint/25 to-liquid-sky/25 border-liquid-mint/50 hover:border-liquid-mint/70 hover:from-liquid-mint/35 hover:to-liquid-sky/35"
|
|
||||||
: idx === 1
|
|
||||||
? "bg-gradient-to-r from-liquid-coral/25 to-liquid-peach/25 border-liquid-coral/50 hover:border-liquid-coral/70 hover:from-liquid-coral/35 hover:to-liquid-peach/35"
|
|
||||||
: idx === 2
|
|
||||||
? "bg-gradient-to-r from-liquid-lavender/25 to-liquid-pink/25 border-liquid-lavender/50 hover:border-liquid-lavender/70 hover:from-liquid-lavender/35 hover:to-liquid-pink/35"
|
|
||||||
: "bg-gradient-to-r from-liquid-lime/25 to-liquid-teal/25 border-liquid-lime/50 hover:border-liquid-lime/70 hover:from-liquid-lime/35 hover:to-liquid-teal/35"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<hobby.icon size={20} className="text-stone-700" />
|
|
||||||
<span className="text-stone-800 font-semibold">
|
|
||||||
{String(hobby.text)}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Currently Reading */}
|
|
||||||
<motion.div
|
|
||||||
variants={fadeInUp}
|
|
||||||
className="mt-8"
|
|
||||||
>
|
|
||||||
<CurrentlyReading />
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 5. Library, Gear & Snippets */}
|
||||||
|
<div className="md:col-span-12 grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
|
||||||
|
{/* Library - Larger Span */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.4 }}
|
||||||
|
className="lg:col-span-7 bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col group overflow-hidden relative min-h-[500px]"
|
||||||
|
>
|
||||||
|
<div className="relative z-10 flex flex-col h-full">
|
||||||
|
<div className="flex justify-between items-center mb-10">
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-3 uppercase tracking-tighter">
|
||||||
|
<BookOpen className="text-liquid-purple" size={24} /> Library
|
||||||
|
</h3>
|
||||||
|
<Link href={`/${locale}/books`} className="group/link flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all">
|
||||||
|
View All <ArrowRight size={14} className="group-hover/link:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CurrentlyReading />
|
||||||
|
<div className="mt-6 flex-1">
|
||||||
|
<ReadBooks />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-5 flex flex-col gap-6 md:gap-8">
|
||||||
|
{/* My Gear (Uses) */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-2xl font-black mb-8 flex items-center gap-3 uppercase tracking-tighter text-white">
|
||||||
|
<Cpu className="text-liquid-mint" size={24} /> My Gear
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Main</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">MacBook M4 Pro</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">PC</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">RTX 3080 / R7</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">Server</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">IONOS & RPi 4</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400">OS</p>
|
||||||
|
<p className="text-sm font-bold text-stone-100">macOS / Linux</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-0 right-0 w-32 h-32 bg-liquid-mint/10 blur-3xl rounded-full -mr-16 -mb-16" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.6 }}
|
||||||
|
className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between group overflow-hidden relative flex-1"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 flex items-center gap-3 uppercase tracking-tighter mb-6">
|
||||||
|
<Terminal className="text-liquid-purple" size={24} /> Snippets
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-12 rounded-xl" />)
|
||||||
|
) : snippets.length > 0 ? (
|
||||||
|
snippets.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setSelectedSnippet(s)}
|
||||||
|
className="w-full text-left p-3 bg-stone-50 dark:bg-stone-800 rounded-xl border border-stone-100 dark:border-stone-700 hover:border-liquid-purple transition-all group/s"
|
||||||
|
>
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-stone-400 mb-0.5 group-hover/s:text-liquid-purple transition-colors">{s.category}</p>
|
||||||
|
<p className="text-xs font-bold text-stone-800 dark:text-stone-200">{s.title}</p>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-stone-400 italic">No snippets yet.</p>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
Enter the Lab <ArrowRight size={12} className="group-hover/btn:translate-x-1 transition-transform" />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 6. Hobbies */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
className="md:col-span-12"
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex flex-col justify-between">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||||
|
{isLoading ? (
|
||||||
|
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)
|
||||||
|
) : (
|
||||||
|
hobbies.map((hobby) => {
|
||||||
|
const Icon = iconMap[hobby.icon] || Lightbulb;
|
||||||
|
return (
|
||||||
|
<div key={hobby.id} className="p-6 bg-stone-50 dark:bg-stone-800 rounded-2xl border border-stone-100 dark:border-stone-700 hover:border-liquid-mint transition-colors group">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Icon size={20} className="text-liquid-mint group-hover:scale-110 transition-transform shrink-0" />
|
||||||
|
<h4 className="font-bold text-sm text-stone-800 dark:text-stone-200 uppercase tracking-tight">{hobby.title}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-stone-500 dark:text-stone-400 font-medium leading-relaxed">
|
||||||
|
{hobby.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 border-t border-stone-100 dark:border-stone-800 pt-8">
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">{t("hobbiesTitle")}</h3>
|
||||||
|
<p className="text-stone-500 font-light text-lg">{locale === 'de' ? 'Neugier über die Softwareentwicklung hinaus.' : 'Curiosity beyond software engineering.'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Snippet Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{selectedSnippet && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="absolute inset-0 bg-stone-950/60 backdrop-blur-md"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="relative w-full max-w-3xl bg-white dark:bg-stone-900 rounded-[2.5rem] shadow-2xl border border-stone-200 dark:border-stone-800 overflow-hidden flex flex-col max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<div className="p-8 md:p-10 overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-start mb-8">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-liquid-purple mb-2">{selectedSnippet.category}</p>
|
||||||
|
<h3 className="text-3xl font-black text-stone-900 dark:text-white uppercase tracking-tighter">{selectedSnippet.title}</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="p-3 bg-stone-50 dark:bg-stone-800 rounded-full hover:bg-stone-100 dark:hover:bg-stone-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-stone-600 dark:text-stone-400 mb-8 leading-relaxed">
|
||||||
|
{selectedSnippet.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative group/code">
|
||||||
|
<div className="absolute top-4 right-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(selectedSnippet.code)}
|
||||||
|
className="p-2.5 bg-white/10 backdrop-blur-md hover:bg-white/20 rounded-lg border border-white/10 transition-all text-white"
|
||||||
|
title="Copy Code"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="bg-stone-950 p-6 rounded-2xl overflow-x-auto text-sm font-mono text-stone-300 border border-stone-800 leading-relaxed">
|
||||||
|
<code>{selectedSnippet.code}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-stone-50 dark:bg-stone-800/50 border-t border-stone-100 dark:border-stone-800 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSnippet(null)}
|
||||||
|
className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Close Laboratory
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
107
app/components/BentoChat.tsx
Normal file
107
app/components/BentoChat.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { Send, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sender: "user" | "bot";
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
sender: "user" | "bot";
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BentoChat() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [conversationId, setConversationId] = useState<string>("default");
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const storedId = localStorage.getItem("chatSessionId");
|
||||||
|
if (storedId) setConversationId(storedId);
|
||||||
|
else {
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
localStorage.setItem("chatSessionId", newId);
|
||||||
|
setConversationId(newId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedMsgs = localStorage.getItem("chatMessages");
|
||||||
|
if (storedMsgs) {
|
||||||
|
setMessages(JSON.parse(storedMsgs).map((m: StoredMessage) => ({ ...m, timestamp: new Date(m.timestamp) })));
|
||||||
|
} else {
|
||||||
|
setMessages([{ id: "welcome", text: "Hi! Ask me anything about Dennis! 🚀", sender: "bot", timestamp: new Date() }]);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) localStorage.setItem("chatMessages", JSON.stringify(messages));
|
||||||
|
scrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!inputValue.trim() || isLoading) return;
|
||||||
|
const userMsg: Message = { id: Date.now().toString(), text: inputValue.trim(), sender: "user", timestamp: new Date() };
|
||||||
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
setInputValue("");
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/n8n/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: userMsg.text, conversationId, history: messages.slice(-5).map(m => ({ role: m.sender === "user" ? "user" : "assistant", content: m.text })) }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: data.reply || "Error", sender: "bot", timestamp: new Date() }]);
|
||||||
|
} catch {
|
||||||
|
setMessages(prev => [...prev, { id: (Date.now() + 1).toString(), text: "Connection error.", sender: "bot", timestamp: new Date() }]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(true);
|
||||||
|
setTimeout(() => setIsLoading(false), 500); // Small delay for feel
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-[300px]">
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 scrollbar-hide space-y-4 mb-4">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={`flex ${m.sender === "user" ? "justify-end" : "justify-start"}`}>
|
||||||
|
<div className={`max-w-[90%] rounded-2xl px-4 py-2 text-sm shadow-sm ${m.sender === "user" ? "bg-liquid-purple text-white" : "bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-100 dark:border-stone-700"}`}>
|
||||||
|
{m.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-stone-100 dark:bg-stone-800 rounded-2xl px-4 py-2"><Loader2 size={14} className="animate-spin text-stone-400" /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={scrollRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
|
placeholder="Ask me..."
|
||||||
|
className="w-full bg-white dark:bg-stone-800 border border-stone-200 dark:border-stone-700 rounded-2xl py-3 pl-4 pr-12 text-sm focus:outline-none focus:ring-2 focus:ring-liquid-purple/30 transition-all shadow-inner dark:text-white"
|
||||||
|
/>
|
||||||
|
<button onClick={handleSend} className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-liquid-purple hover:scale-110 transition-transform">
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,18 +7,14 @@ import { ToastProvider } from "@/components/Toast";
|
|||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||||
import { ConsentProvider, useConsent } from "./ConsentProvider";
|
import { ConsentProvider, useConsent } from "./ConsentProvider";
|
||||||
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
// Dynamic import with SSR disabled to avoid framer-motion issues
|
|
||||||
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
|
const BackgroundBlobs = dynamic(() => import("@/components/BackgroundBlobs").catch(() => ({ default: () => null })), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => null,
|
loading: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
|
|
||||||
ssr: false,
|
|
||||||
loading: () => null,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function ClientProviders({
|
export default function ClientProviders({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -72,9 +68,21 @@ export default function ClientProviders({
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConsentProvider>
|
<ConsentProvider>
|
||||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||||
{children}
|
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
||||||
</GatedProviders>
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
<motion.div
|
||||||
|
key={pathname}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</GatedProviders>
|
||||||
|
</ThemeProvider>
|
||||||
</ConsentProvider>
|
</ConsentProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -84,27 +92,21 @@ export default function ClientProviders({
|
|||||||
function GatedProviders({
|
function GatedProviders({
|
||||||
children,
|
children,
|
||||||
mounted,
|
mounted,
|
||||||
is404Page,
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
is404Page: boolean;
|
is404Page: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { consent } = useConsent();
|
const { consent } = useConsent();
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const isAdminRoute = pathname.startsWith("/manage") || pathname.startsWith("/editor");
|
|
||||||
|
|
||||||
// If consent is not decided yet, treat optional features as off
|
// If consent is not decided yet, treat optional features as off
|
||||||
const analyticsEnabled = !!consent?.analytics;
|
const analyticsEnabled = !!consent?.analytics;
|
||||||
const chatEnabled = !!consent?.chat;
|
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{mounted && <BackgroundBlobs />}
|
{mounted && <BackgroundBlobs />}
|
||||||
<div className="relative z-10">{children}</div>
|
<div className="relative z-10">{children}</div>
|
||||||
{mounted && !is404Page && !isAdminRoute && chatEnabled && <ChatWidget />}
|
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
|||||||
return locale.startsWith('de') ? 'de' : 'en';
|
return locale.startsWith('de') ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeroClient({ locale, translations }: { locale: string; translations: HeroTranslations }) {
|
export function HeroClient({ locale }: { locale: string; translations: HeroTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export function HeroClient({ locale, translations }: { locale: string; translati
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AboutClient({ locale, translations }: { 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];
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export function AboutClient({ locale, translations }: { locale: string; translat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsClient({ locale, translations }: { locale: string; translations: ProjectsTranslations }) {
|
export function ProjectsClient({ locale }: { locale: string; translations: ProjectsTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ export function ProjectsClient({ locale, translations }: { locale: string; trans
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactClient({ locale, translations }: { locale: string; translations: ContactTranslations }) {
|
export function ContactClient({ locale }: { locale: string; translations: ContactTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export function ContactClient({ locale, translations }: { locale: string; transl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FooterClient({ locale, translations }: { locale: string; translations: FooterTranslations }) {
|
export function FooterClient({ locale }: { locale: string; translations: FooterTranslations }) {
|
||||||
const normalLocale = getNormalizedLocale(locale);
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
const baseMessages = messageMap[normalLocale];
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Mail, MapPin, Send } from "lucide-react";
|
import { Mail, MapPin, Send, Github, Linkedin } from "lucide-react";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import type { JSONContent } from "@tiptap/react";
|
||||||
@@ -152,118 +152,120 @@ const Contact = () => {
|
|||||||
validateForm();
|
validateForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactInfo = [
|
|
||||||
{
|
|
||||||
icon: Mail,
|
|
||||||
title: tInfo("email"),
|
|
||||||
value: "contact@dk0.dev",
|
|
||||||
href: "mailto:contact@dk0.dev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MapPin,
|
|
||||||
title: tInfo("location"),
|
|
||||||
value: tInfo("locationValue"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="contact"
|
id="contact"
|
||||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-teal/15 via-liquid-mint/10 to-liquid-lime/15"
|
className="py-32 px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500"
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Section Header */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
{/* Header Card */}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="text-center mb-16"
|
|
||||||
>
|
|
||||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
|
|
||||||
{t("title")}
|
|
||||||
</h2>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
|
|
||||||
) : (
|
|
||||||
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
|
|
||||||
{t("subtitle")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
|
||||||
{/* Contact Information */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -20 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
className="md:col-span-12 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"
|
||||||
className="space-y-8"
|
|
||||||
>
|
>
|
||||||
<div>
|
<div className="max-w-3xl">
|
||||||
<h3 className="text-2xl font-bold text-stone-900 mb-6">
|
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-8">
|
||||||
{t("getInTouch")}
|
{t("title")}<span className="text-liquid-mint">.</span>
|
||||||
</h3>
|
</h2>
|
||||||
<p className="text-stone-700 leading-relaxed">
|
{cmsDoc ? (
|
||||||
{t("getInTouchBody")}
|
<RichTextClient doc={cmsDoc} className="prose prose-stone dark:prose-invert max-w-none text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400" />
|
||||||
</p>
|
) : (
|
||||||
</div>
|
<p className="text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
{t("subtitle")}
|
||||||
{/* Contact Details */}
|
</p>
|
||||||
<div className="space-y-4">
|
)}
|
||||||
{contactInfo.map((info, index) => (
|
|
||||||
<motion.a
|
|
||||||
key={info.title}
|
|
||||||
href={info.href}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
delay: index * 0.1,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
}}
|
|
||||||
whileHover={{
|
|
||||||
x: 8,
|
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
|
||||||
}}
|
|
||||||
className="flex items-center space-x-4 p-4 rounded-2xl glass-card hover:bg-white/80 transition-[background-color,border-color,box-shadow] duration-500 ease-out group border-transparent hover:border-white/70"
|
|
||||||
>
|
|
||||||
<div className="p-3 bg-white rounded-xl shadow-sm group-hover:shadow-md transition-all">
|
|
||||||
<info.icon className="w-6 h-6 text-stone-700" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-stone-800">
|
|
||||||
{info.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-stone-500">{info.value}</p>
|
|
||||||
</div>
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Contact Form */}
|
{/* Info Side (Unified Connect Box) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
whileInView={{ opacity: 1, x: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
transition={{ delay: 0.1 }}
|
||||||
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
|
className="md:col-span-12 lg:col-span-4 flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<h3 className="text-2xl font-bold text-gray-800 mb-6">
|
<div className="bg-white dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between relative overflow-hidden group">
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex justify-between items-center mb-12">
|
||||||
|
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Connect</h4>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-emerald-500/10 rounded-full border border-emerald-500/20">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-emerald-600 dark:text-emerald-400">Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Email */}
|
||||||
|
<a href="mailto:contact@dk0.dev" className="flex items-center justify-between group/link">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Email</span>
|
||||||
|
<span className="text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">contact@dk0.dev</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
|
<Mail size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
||||||
|
|
||||||
|
{/* GitHub */}
|
||||||
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Code</span>
|
||||||
|
<span className="text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-liquid-mint transition-colors">GitHub</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
|
<Github size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="h-px bg-stone-100 dark:bg-stone-800 w-full" />
|
||||||
|
|
||||||
|
{/* LinkedIn */}
|
||||||
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="flex items-center justify-between group/link">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-[0.2em] text-stone-400 mb-1">Professional</span>
|
||||||
|
<span className="text-2xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase group-hover/link:text-[#0077b5] transition-colors">LinkedIn</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-stone-100 dark:border-stone-800 flex items-center justify-center group-hover/link:bg-stone-900 dark:group-hover/link:bg-stone-50 group-hover/link:text-white dark:group-hover/link:text-stone-900 transition-all">
|
||||||
|
<Linkedin size={16} />
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 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>
|
||||||
|
<div className="flex items-center gap-2 text-stone-900 dark:text-stone-50">
|
||||||
|
<MapPin size={14} className="text-liquid-mint" />
|
||||||
|
<span className="font-bold">{tInfo("locationValue")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Form Side */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-12 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||||
|
>
|
||||||
|
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter mb-10">
|
||||||
{tForm("title")}
|
{tForm("title")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||||
htmlFor="name"
|
{tForm("labels.name")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Name <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -273,32 +275,14 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||||
errors.name && touched.name
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.name")}
|
placeholder={tForm("placeholders.name")}
|
||||||
aria-invalid={
|
|
||||||
errors.name && touched.name ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.name && touched.name ? "name-error" : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{errors.name && touched.name && (
|
|
||||||
<p id="name-error" className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||||
htmlFor="email"
|
{tForm("labels.email")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Email <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -308,33 +292,15 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||||
errors.email && touched.email
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.email")}
|
placeholder={tForm("placeholders.email")}
|
||||||
aria-invalid={
|
|
||||||
errors.email && touched.email ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.email && touched.email ? "email-error" : undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{errors.email && touched.email && (
|
|
||||||
<p id="email-error" className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||||
htmlFor="subject"
|
{tForm("labels.subject")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Subject <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -344,34 +310,14 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all ${
|
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium"
|
||||||
errors.subject && touched.subject
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.subject")}
|
placeholder={tForm("placeholders.subject")}
|
||||||
aria-invalid={
|
|
||||||
errors.subject && touched.subject ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.subject && touched.subject
|
|
||||||
? "subject-error"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
{errors.subject && touched.subject && (
|
|
||||||
<p id="subject-error" className="mt-1 text-sm text-red-500">
|
|
||||||
{errors.subject}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label
|
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||||
htmlFor="message"
|
{tForm("labels.message")}
|
||||||
className="block text-sm font-medium text-stone-600 mb-2"
|
|
||||||
>
|
|
||||||
Message <span className="text-liquid-rose">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
@@ -380,53 +326,25 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required
|
required
|
||||||
rows={6}
|
rows={5}
|
||||||
className={`w-full px-4 py-3 bg-white/50 backdrop-blur-sm border rounded-xl text-stone-800 placeholder-stone-400 focus:outline-none focus:ring-2 transition-all resize-none ${
|
className="w-full px-6 py-4 bg-stone-50 dark:bg-stone-800 border border-stone-100 dark:border-stone-700 rounded-2xl text-stone-900 dark:text-stone-50 focus:outline-none focus:ring-2 focus:ring-liquid-mint/50 transition-all font-medium resize-none"
|
||||||
errors.message && touched.message
|
|
||||||
? "border-red-400 focus:ring-red-400"
|
|
||||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
|
||||||
}`}
|
|
||||||
placeholder={tForm("placeholders.message")}
|
placeholder={tForm("placeholders.message")}
|
||||||
aria-invalid={
|
|
||||||
errors.message && touched.message ? "true" : "false"
|
|
||||||
}
|
|
||||||
aria-describedby={
|
|
||||||
errors.message && touched.message
|
|
||||||
? "message-error"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-1">
|
|
||||||
{errors.message && touched.message ? (
|
|
||||||
<p id="message-error" className="text-sm text-red-500">
|
|
||||||
{errors.message}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<span></span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs text-stone-400">
|
|
||||||
{tForm("characters", { count: formData.message.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
|
whileHover={{ scale: 1.01 }}
|
||||||
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
whileTap={{ scale: 0.99 }}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
className="w-full py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-black text-xs uppercase tracking-[0.3em] flex items-center justify-center gap-3 shadow-xl hover:shadow-2xl transition-all disabled:opacity-50"
|
||||||
className="w-full py-4 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2 disabled:hover:scale-100 disabled:hover:translate-y-0 bg-stone-900 text-white rounded-xl hover:bg-stone-950 transition-all duration-500 ease-out shadow-lg hover:shadow-xl"
|
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
|
||||||
<span>{tForm("sending")}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send size={20} />
|
<Send size={16} />
|
||||||
<span className="text-cream">{tForm("send")}</span>
|
{tForm("send")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { motion } from "framer-motion";
|
|||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
|
|
||||||
interface CurrentlyReading {
|
interface CurrentlyReading {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -53,8 +55,26 @@ const CurrentlyReading = () => {
|
|||||||
fetchCurrentlyReading();
|
fetchCurrentlyReading();
|
||||||
}, []); // Leeres Array = nur einmal beim Mount
|
}, []); // Leeres Array = nur einmal beim Mount
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
<Skeleton className="w-24 h-36 sm:w-28 sm:h-40 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 space-y-3 w-full">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<div className="space-y-2 pt-4">
|
||||||
|
<Skeleton className="h-2 w-full" />
|
||||||
|
<Skeleton className="h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
|
// Zeige nichts wenn kein Buch gelesen wird oder noch geladen wird
|
||||||
if (loading || books.length === 0) {
|
if (books.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +82,8 @@ const CurrentlyReading = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<BookOpen size={18} className="text-stone-600 flex-shrink-0" />
|
<BookOpen size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
||||||
<h3 className="text-lg font-bold text-stone-900">
|
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
||||||
{t("title")} {books.length > 1 && `(${books.length})`}
|
{t("title")} {books.length > 1 && `(${books.length})`}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,11 +100,11 @@ const CurrentlyReading = () => {
|
|||||||
scale: 1.02,
|
scale: 1.02,
|
||||||
transition: { duration: 0.4, ease: "easeOut" },
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
}}
|
}}
|
||||||
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 border-2 border-liquid-lavender/30 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
|
className="relative overflow-hidden bg-gradient-to-br from-liquid-lavender/15 via-liquid-pink/10 to-liquid-rose/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-lavender/30 dark:border-stone-700 rounded-xl p-6 backdrop-blur-sm hover:border-liquid-lavender/50 dark:hover:border-stone-600 hover:from-liquid-lavender/20 hover:via-liquid-pink/15 hover:to-liquid-rose/20 transition-all duration-500 ease-out"
|
||||||
>
|
>
|
||||||
{/* Background Blob Animation */}
|
{/* Background Blob Animation */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 rounded-full blur-2xl"
|
className="absolute -top-10 -right-10 w-32 h-32 bg-gradient-to-br from-liquid-lavender/20 to-liquid-pink/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
||||||
animate={{
|
animate={{
|
||||||
scale: [1, 1.2, 1],
|
scale: [1, 1.2, 1],
|
||||||
opacity: [0.3, 0.5, 0.3],
|
opacity: [0.3, 0.5, 0.3],
|
||||||
@@ -106,12 +126,13 @@ const CurrentlyReading = () => {
|
|||||||
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
>
|
>
|
||||||
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50">
|
<div className="relative w-24 h-36 sm:w-28 sm:h-40 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
||||||
<img
|
<Image
|
||||||
src={book.image}
|
src={book.image}
|
||||||
alt={book.title}
|
alt={book.title}
|
||||||
className="w-full h-full object-cover"
|
fill
|
||||||
loading="lazy"
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 96px, 112px"
|
||||||
/>
|
/>
|
||||||
{/* Glossy Overlay */}
|
{/* Glossy Overlay */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
||||||
@@ -122,25 +143,25 @@ const CurrentlyReading = () => {
|
|||||||
{/* Book Info */}
|
{/* Book Info */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h4 className="text-lg font-bold text-stone-900 mb-1 line-clamp-2">
|
<h4 className="text-lg font-bold text-stone-900 dark:text-stone-100 mb-1 line-clamp-2">
|
||||||
{book.title}
|
{book.title}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
{/* Authors */}
|
{/* Authors */}
|
||||||
<p className="text-sm text-stone-600 mb-4 line-clamp-1">
|
<p className="text-sm text-stone-600 dark:text-stone-400 mb-4 line-clamp-1">
|
||||||
{book.authors.join(", ")}
|
{book.authors.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs text-stone-600">
|
<div className="flex items-center justify-between text-xs text-stone-600 dark:text-stone-400">
|
||||||
<span>{t("progress")}</span>
|
<span>{t("progress")}</span>
|
||||||
<span className="font-semibold">{book.progress}%</span>
|
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-2 bg-white/50 rounded-full overflow-hidden border border-white/70">
|
<div className="relative h-2 bg-white/50 dark:bg-stone-700 rounded-full overflow-hidden border border-white/70 dark:border-stone-600">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${book.progress}%` }}
|
animate={{ width: `${isNaN(book.progress) ? 0 : book.progress}%` }}
|
||||||
transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
|
transition={{ duration: 1, delay: 0.3 + index * 0.1, ease: "easeOut" }}
|
||||||
className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
|
className="absolute left-0 top-0 h-full bg-gradient-to-r from-liquid-lavender via-liquid-pink to-liquid-rose rounded-full shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,142 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
import Link from "next/link";
|
||||||
import { Heart, Code } from 'lucide-react';
|
|
||||||
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useConsent } from "./ConsentProvider";
|
import { ArrowUp } from "lucide-react";
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("footer");
|
const t = useTranslations("footer");
|
||||||
const { resetConsent } = useConsent();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const [currentYear] = useState(() => new Date().getFullYear());
|
const scrollToTop = () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
const socialLinks = [
|
};
|
||||||
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
|
||||||
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative py-12 px-4 bg-white border-t border-stone-200">
|
<footer className="bg-[#fdfcf8] dark:bg-stone-950 pt-32 pb-12 px-6 overflow-hidden transition-colors duration-500">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
|
|
||||||
{/* Brand */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 items-end">
|
||||||
<motion.div
|
{/* Copyright & Info */}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<div className="md:col-span-4 space-y-6">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<div className="w-12 h-12 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
dk
|
||||||
transition={{ duration: 0.4 }}
|
|
||||||
className="flex items-center space-x-3"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
whileHover={{ rotate: 360, scale: 1.1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
className="w-12 h-12 bg-gradient-to-br from-liquid-mint to-liquid-lavender rounded-xl flex items-center justify-center shadow-md"
|
|
||||||
>
|
|
||||||
<Code className="w-6 h-6 text-stone-800" />
|
|
||||||
</motion.div>
|
|
||||||
<div>
|
|
||||||
<Link href={`/${locale}`} className="text-xl font-bold font-mono text-stone-800 hover:text-liquid-blue transition-colors">
|
|
||||||
dk<span className="text-liquid-rose">0</span>
|
|
||||||
</Link>
|
|
||||||
<p className="text-xs text-stone-500">{t("role")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
<div className="space-y-2">
|
||||||
|
<p className="text-xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter">Software Engineer</p>
|
||||||
|
<p className="text-stone-500 text-sm font-medium">© {year} All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Social Links */}
|
{/* Navigation Links */}
|
||||||
<motion.div
|
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<div className="space-y-4">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Legal</p>
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
<div className="flex flex-col gap-2">
|
||||||
transition={{ duration: 0.4, delay: 0.05 }}
|
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
||||||
className="flex space-x-3"
|
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
||||||
>
|
</div>
|
||||||
{socialLinks.map((social) => (
|
</div>
|
||||||
<motion.a
|
<div className="space-y-4">
|
||||||
key={social.label}
|
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Social</p>
|
||||||
href={social.href}
|
<div className="flex flex-col gap-2">
|
||||||
target="_blank"
|
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
|
||||||
rel="noopener noreferrer"
|
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
|
||||||
whileHover={{ scale: 1.15, y: -3 }}
|
</div>
|
||||||
whileTap={{ scale: 0.95 }}
|
</div>
|
||||||
className="p-3 bg-stone-50 hover:bg-white rounded-xl text-stone-600 hover:text-stone-900 transition-all duration-200 border border-stone-200 hover:border-stone-300 shadow-sm"
|
</div>
|
||||||
aria-label={social.label}
|
|
||||||
>
|
|
||||||
<social.icon size={18} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Copyright */}
|
{/* Back to Top */}
|
||||||
<motion.div
|
<div className="md:col-span-4 flex justify-start md:justify-end">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<button
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
onClick={scrollToTop}
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
className="group flex flex-col items-center gap-4 text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
transition={{ duration: 0.4, delay: 0.1 }}
|
|
||||||
className="flex items-center space-x-2 text-stone-400 text-sm"
|
|
||||||
>
|
|
||||||
<span>© {currentYear}</span>
|
|
||||||
<motion.div
|
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
|
||||||
>
|
>
|
||||||
<Heart size={14} className="text-liquid-rose fill-liquid-rose" />
|
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||||
</motion.div>
|
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
|
||||||
<span>{t("madeIn")}</span>
|
<ArrowUp size={20} />
|
||||||
</motion.div>
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legal Links */}
|
{/* Bottom Bar */}
|
||||||
<motion.div
|
<div className="mt-20 pt-8 border-t border-stone-100 dark:border-stone-900 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
Built with Next.js, Directus & Passion.
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
</p>
|
||||||
transition={{ duration: 0.4, delay: 0.15 }}
|
<div className="flex items-center gap-2">
|
||||||
className="mt-8 pt-6 border-t border-stone-100 flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0"
|
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
>
|
<span className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||||
<div className="flex space-x-6 text-sm">
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/legal-notice`}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{t("legalNotice")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/privacy-policy`}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{t("privacyPolicy")}
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => resetConsent()}
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200"
|
|
||||||
title={t("privacySettingsTitle")}
|
|
||||||
>
|
|
||||||
{t("privacySettings")}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
href="/404"
|
|
||||||
className="text-stone-500 hover:text-stone-800 transition-colors duration-200 font-mono text-xs"
|
|
||||||
title="Kernel Panic 404"
|
|
||||||
>
|
|
||||||
404
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="text-xs text-stone-400 flex items-center space-x-1">
|
|
||||||
<span>{t("builtWith")}</span>
|
|
||||||
<span className="text-stone-600 font-semibold">Next.js</span>
|
|
||||||
<span className="text-stone-300">•</span>
|
|
||||||
<span className="text-stone-600 font-semibold">TypeScript</span>
|
|
||||||
<span className="text-stone-300">•</span>
|
|
||||||
<span className="text-stone-600 font-semibold">Tailwind CSS</span>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
30
app/components/Grain.tsx
Normal file
30
app/components/Grain.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"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,32 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Menu, X, Mail } from "lucide-react";
|
import { Menu, X } from "lucide-react";
|
||||||
import { SiGithub, SiLinkedin } from "react-icons/si";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const t = useTranslations("nav");
|
const t = useTranslations("nav");
|
||||||
|
|
||||||
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
setScrolled(window.scrollY > 50);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: t("home"), href: `/${locale}` },
|
{ name: t("home"), href: `/${locale}` },
|
||||||
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
||||||
@@ -34,232 +23,82 @@ const Header = () => {
|
|||||||
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
{ name: t("contact"), href: isHome ? "#contact" : `/${locale}#contact` },
|
||||||
];
|
];
|
||||||
|
|
||||||
const socialLinks = [
|
|
||||||
{ icon: SiGithub, href: "https://github.com/Denshooter", label: "GitHub" },
|
|
||||||
{
|
|
||||||
icon: SiLinkedin,
|
|
||||||
href: "https://linkedin.com/in/dkonkol",
|
|
||||||
label: "LinkedIn",
|
|
||||||
},
|
|
||||||
{ icon: Mail, href: "mailto:contact@dk0.dev", label: "Email" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const pathWithoutLocale = pathname.replace(new RegExp(`^/${locale}`), "") || "";
|
|
||||||
const qs = searchParams.toString();
|
|
||||||
const query = qs ? `?${qs}` : "";
|
|
||||||
const enHref = `/en${pathWithoutLocale}${query}`;
|
|
||||||
const deHref = `/de${pathWithoutLocale}${query}`;
|
|
||||||
|
|
||||||
// Always render to prevent flash, but use opacity transition
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.header
|
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
|
||||||
initial={false}
|
<motion.nav
|
||||||
animate={{ y: 0, opacity: 1 }}
|
initial={{ y: -100, opacity: 0 }}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
|
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"
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`pointer-events-auto transition-all duration-500 ease-out ${
|
|
||||||
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
{/* Logo Pill */}
|
||||||
initial={false}
|
<Link
|
||||||
animate={{ opacity: 1, y: 0 }}
|
href={`/${locale}`}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
|
||||||
className={`
|
|
||||||
backdrop-blur-xl transition-all duration-500
|
|
||||||
${
|
|
||||||
scrolled
|
|
||||||
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
|
|
||||||
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
|
|
||||||
}
|
|
||||||
flex justify-between items-center
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<span className="font-black text-xs tracking-tighter">dk</span>
|
||||||
whileHover={{ scale: 1.05 }}
|
</Link>
|
||||||
className="flex items-center space-x-2"
|
|
||||||
>
|
{/* Desktop Menu */}
|
||||||
|
<div className="hidden md:flex items-center gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
key={item.name}
|
||||||
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
|
href={item.href}
|
||||||
|
className="px-5 py-2 text-[10px] font-black uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-all"
|
||||||
>
|
>
|
||||||
dk<span className="text-red-500">0</span>
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="hidden md:flex items-center space-x-8">
|
<div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1 hidden md:block"></div>
|
||||||
|
|
||||||
|
{/* Actions Pill */}
|
||||||
|
<div className="flex items-center gap-1 bg-stone-100/50 dark:bg-white/5 rounded-full p-1">
|
||||||
|
<Link
|
||||||
|
href={locale === "en" ? pathname.replace(/^\/en/, "/de") : pathname.replace(/^\/de/, "/en")}
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-[10px] font-black text-stone-500 hover:text-stone-900 dark:hover:text-stone-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
{locale === "en" ? "DE" : "EN"}
|
||||||
|
</Link>
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* Mobile Menu Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-8 h-8 flex md:hidden items-center justify-center text-stone-600 dark:text-stone-400 hover:bg-white dark:hover:bg-stone-800 rounded-full transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
{isOpen ? <X size={14} /> : <Menu size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||||
|
className="fixed top-24 left-6 right-6 z-40 bg-white/90 dark:bg-stone-900/95 backdrop-blur-3xl border border-white/40 dark:border-white/10 rounded-[2.5rem] shadow-2xl p-6 md:hidden overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<motion.div
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
whileHover={{ y: -2 }}
|
href={item.href}
|
||||||
whileTap={{ scale: 0.95 }}
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
|
||||||
>
|
>
|
||||||
<Link
|
{item.name}
|
||||||
href={item.href}
|
|
||||||
className="text-stone-600 hover:text-stone-900 transition-colors duration-200 font-medium relative group px-2 py-1 liquid-hover"
|
|
||||||
onClick={(e) => {
|
|
||||||
if (item.href.startsWith("#")) {
|
|
||||||
e.preventDefault();
|
|
||||||
const element = document.querySelector(item.href);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
<motion.span
|
|
||||||
className="absolute -bottom-1 left-0 right-0 h-0.5 bg-gradient-to-r from-liquid-mint to-liquid-lavender rounded-full"
|
|
||||||
initial={{ scaleX: 0, opacity: 0 }}
|
|
||||||
whileHover={{ scaleX: 1, opacity: 1 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.4,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
}}
|
|
||||||
style={{ transformOrigin: "left center" }}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="hidden md:flex items-center space-x-3">
|
|
||||||
<div className="flex items-center bg-white/40 border border-white/50 rounded-full overflow-hidden shadow-sm">
|
|
||||||
<Link
|
|
||||||
href={enHref}
|
|
||||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
|
||||||
locale === "en"
|
|
||||||
? "bg-stone-900 text-stone-50"
|
|
||||||
: "text-stone-700 hover:bg-white/60"
|
|
||||||
}`}
|
|
||||||
aria-label="Switch language to English"
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href={deHref}
|
|
||||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
|
||||||
locale === "de"
|
|
||||||
? "bg-stone-900 text-stone-50"
|
|
||||||
: "text-stone-700 hover:bg-white/60"
|
|
||||||
}`}
|
|
||||||
aria-label="Sprache auf Deutsch umstellen"
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{socialLinks.map((social) => (
|
|
||||||
<motion.a
|
|
||||||
key={social.label}
|
|
||||||
href={social.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
className="p-2 rounded-full bg-white/40 hover:bg-white/80 border border-white/50 text-stone-600 hover:text-stone-900 transition-all shadow-sm liquid-hover"
|
|
||||||
>
|
|
||||||
<social.icon size={18} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.button
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover"
|
|
||||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
|
||||||
>
|
|
||||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
|
||||||
</motion.button>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-40 md:hidden pointer-events-auto"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
|
||||||
transition={{ duration: 0.3, type: "spring" }}
|
|
||||||
className="absolute top-24 left-4 right-4 bg-cream/95 backdrop-blur-xl border border-stone-200 shadow-xl rounded-3xl z-50 p-6 pointer-events-auto"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{navItems.map((item, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={item.name}
|
|
||||||
initial={{ x: -20, opacity: 0 }}
|
|
||||||
animate={{ x: 0, opacity: 1 }}
|
|
||||||
exit={{ x: -20, opacity: 0 }}
|
|
||||||
transition={{ delay: index * 0.05 }}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={item.href}
|
|
||||||
onClick={(e) => {
|
|
||||||
setIsOpen(false);
|
|
||||||
if (item.href.startsWith("#")) {
|
|
||||||
e.preventDefault();
|
|
||||||
setTimeout(() => {
|
|
||||||
const element = document.querySelector(item.href);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="block text-stone-600 hover:text-stone-900 hover:bg-white/50 transition-all font-medium py-3 px-4 rounded-xl"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="pt-6 mt-4 border-t border-stone-200">
|
|
||||||
<div className="flex justify-center space-x-4">
|
|
||||||
{socialLinks.map((social, index) => (
|
|
||||||
<motion.a
|
|
||||||
key={social.label}
|
|
||||||
href={social.href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
transition={{
|
|
||||||
delay: (navItems.length + index) * 0.05,
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
className="p-3 rounded-full bg-white/60 text-stone-600 shadow-sm"
|
|
||||||
aria-label={social.label}
|
|
||||||
>
|
|
||||||
<social.icon size={20} />
|
|
||||||
</motion.a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.header>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,251 +1,138 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowDown, Code, Zap, Rocket } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import type { JSONContent } from "@tiptap/react";
|
import Image from "next/image";
|
||||||
import RichTextClient from "./RichTextClient";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const Hero = () => {
|
const Hero = () => {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("home.hero");
|
const t = useTranslations("home.hero");
|
||||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(`/api/messages?locale=${locale}`);
|
||||||
`/api/content/page?key=${encodeURIComponent("home-hero")}&locale=${encodeURIComponent(locale)}`,
|
if (res.ok) {
|
||||||
);
|
const data = await res.json();
|
||||||
const data = await res.json();
|
setCmsMessages(data.messages || {});
|
||||||
// Only use CMS content if it exists for the active locale.
|
|
||||||
// If the API falls back to another locale, keep showing next-intl strings
|
|
||||||
// so the locale switch visibly changes the page.
|
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore; fallback to static
|
|
||||||
setCmsDoc(null);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const features = [
|
// Helper to get CMS text or fallback
|
||||||
{ icon: Code, text: t("features.f1") },
|
const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
|
||||||
{ icon: Zap, text: t("features.f2") },
|
|
||||||
{ icon: Rocket, text: t("features.f3") },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden pt-32 pb-16 bg-gradient-to-br from-liquid-mint/10 via-liquid-lavender/10 to-liquid-rose/10">
|
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
||||||
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
|
{/* Liquid Ambient Background */}
|
||||||
{/* Profile Image with Organic Blob Mask */}
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }}
|
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
|
||||||
className="mb-12 flex justify-center relative z-20"
|
/>
|
||||||
>
|
<motion.div
|
||||||
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
|
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
|
||||||
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
|
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
|
||||||
<motion.div
|
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
|
||||||
className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10"
|
/>
|
||||||
animate={{
|
</div>
|
||||||
borderRadius: [
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
],
|
|
||||||
rotate: [0, 120, 0],
|
|
||||||
scale: [1, 1.08, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 35,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<motion.div
|
|
||||||
className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10"
|
|
||||||
animate={{
|
|
||||||
borderRadius: [
|
|
||||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
|
||||||
"60% 30% 40% 70%/60% 40% 70% 30%",
|
|
||||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
|
||||||
],
|
|
||||||
rotate: [0, -90, 0],
|
|
||||||
scale: [1, 1.05, 1],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 40,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* The Image Container with Organic Border Radius */}
|
<div className="relative z-10 max-w-7xl mx-auto w-full pt-20">
|
||||||
<motion.div
|
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-24">
|
||||||
className="absolute inset-0 overflow-hidden bg-stone-100"
|
|
||||||
style={{
|
{/* Left: Text Content */}
|
||||||
filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))",
|
<div className="flex-1 text-center lg:text-left space-y-10">
|
||||||
willChange: "border-radius",
|
<motion.div
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
borderRadius: [
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
|
||||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 12,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "easeInOut",
|
|
||||||
repeatType: "reverse",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Use a plain <img> to fully bypass Next.js image optimizer (dev 400 issue). */}
|
|
||||||
<img
|
|
||||||
src="/images/me.jpg"
|
|
||||||
alt="Dennis Konkol"
|
|
||||||
className="absolute inset-0 w-full h-full object-cover scale-105 hover:scale-[1.08] transition-transform duration-1000 ease-out"
|
|
||||||
loading="eager"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Glossy Overlay for Liquid Feel */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-white/25 via-transparent to-white/10 opacity-60 pointer-events-none z-10" />
|
|
||||||
|
|
||||||
{/* Inner Border/Highlight */}
|
|
||||||
<div className="absolute inset-0 border-[2px] border-white/30 rounded-[inherit] pointer-events-none z-20" />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Domain Badge - repositioned below image */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
|
transition={{ duration: 0.5 }}
|
||||||
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
|
className="inline-flex items-center gap-3 px-5 py-2.5 rounded-full bg-white dark:bg-stone-900 border border-stone-200 dark:border-stone-800 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="px-6 py-2.5 rounded-full bg-white/90 backdrop-blur-xl text-stone-900 font-sans font-bold text-sm tracking-wide shadow-lg border-2 border-stone-300">
|
<span className="w-2.5 h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||||
dk<span className="text-red-500 font-extrabold">0</span>.dev
|
<span className="font-mono text-[10px] font-black uppercase tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Floating Badges - subtle animations */}
|
<h1 className="text-6xl md:text-[9.5rem] font-black tracking-tighter leading-[0.8] text-stone-900 dark:text-stone-50 uppercase">
|
||||||
<motion.div
|
<motion.span
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
initial={{ opacity: 0, x: -50 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
|
transition={{ duration: 0.8, delay: 0.1 }}
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
className="block"
|
||||||
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
>
|
||||||
|
{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-4"
|
||||||
|
>
|
||||||
|
{getLabel("hero.line2", "Stuff.")}
|
||||||
|
</motion.span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 1, delay: 0.4 }}
|
||||||
|
className="text-xl md:text-2xl text-stone-600 dark:text-stone-400 max-w-xl mx-auto lg:mx-0 font-light leading-relaxed tracking-tight"
|
||||||
>
|
>
|
||||||
<Code size={24} />
|
{t("description")}
|
||||||
</motion.div>
|
</motion.p>
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
<motion.div
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
whileHover={{ scale: 1.1, rotate: -5 }}
|
transition={{ duration: 0.6, delay: 0.6 }}
|
||||||
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
className="flex flex-col sm:flex-row items-center gap-8 justify-center lg:justify-start pt-4"
|
||||||
>
|
>
|
||||||
<Zap size={24} />
|
<a href="#projects" className="group relative px-12 py-5 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-3xl font-black text-xs uppercase tracking-[0.2em] hover:scale-105 active:scale-95 transition-all shadow-2xl">
|
||||||
|
<div className="absolute inset-0 bg-white/10 -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
||||||
|
{t("ctaWork")}
|
||||||
|
</a>
|
||||||
|
<a href="#contact" className="font-black text-xs uppercase tracking-[0.2em] text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors">
|
||||||
|
{t("ctaContact")}
|
||||||
|
</a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Main Title */}
|
{/* Right: The Photo */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{
|
||||||
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
opacity: 1,
|
||||||
className="mb-8 flex flex-col items-center justify-center relative"
|
scale: 1
|
||||||
>
|
}}
|
||||||
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2">
|
transition={{
|
||||||
Dennis Konkol
|
opacity: { duration: 1 },
|
||||||
</h1>
|
scale: { duration: 1 }
|
||||||
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 mt-2">
|
}}
|
||||||
Software Engineer
|
className="relative w-72 h-72 md:w-[500px] md:h-[500px] shrink-0 mt-12 lg:mt-0"
|
||||||
</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
|
|
||||||
>
|
|
||||||
{cmsDoc ? (
|
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
|
|
||||||
) : (
|
|
||||||
<p>{t("description")}</p>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="flex flex-wrap justify-center gap-4 mb-12"
|
|
||||||
>
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<motion.div
|
|
||||||
key={feature.text}
|
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{
|
|
||||||
duration: 0.5,
|
|
||||||
delay: 0.5 + index * 0.1,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
}}
|
|
||||||
whileHover={{ scale: 1.03, y: -3 }}
|
|
||||||
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/85 border-2 border-stone-300 shadow-md backdrop-blur-lg"
|
|
||||||
>
|
|
||||||
<feature.icon className="w-4 h-4 text-stone-800" />
|
|
||||||
<span className="text-stone-800 font-semibold text-sm">
|
|
||||||
{feature.text}
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.6, delay: 0.6, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="flex flex-col sm:flex-row gap-5 justify-center items-center"
|
|
||||||
>
|
|
||||||
<motion.a
|
|
||||||
href="#projects"
|
|
||||||
whileHover={{ scale: 1.03, y: -2 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
||||||
className="px-8 py-4 bg-stone-900 text-cream rounded-full shadow-lg hover:shadow-xl hover:bg-stone-950 transition-all duration-500 flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<span className="text-cream">{t("ctaWork")}</span>
|
<div className="absolute inset-0 bg-gradient-to-tr from-liquid-mint to-liquid-purple rounded-[5rem] rotate-12 scale-90 opacity-20 blur-3xl" />
|
||||||
<ArrowDown size={18} />
|
<div className="relative w-full h-full rounded-[4rem] overflow-hidden border-[24px] border-white dark:border-stone-900 shadow-[0_50px_100px_-20px_rgba(0,0,0,0.4)]">
|
||||||
</motion.a>
|
<Image src="/images/me.jpg" alt="Dennis Konkol" fill className="object-cover" priority />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white dark:bg-stone-800 px-8 py-4 rounded-[2rem] shadow-2xl border border-stone-100 dark:border-stone-700">
|
||||||
|
<span className="font-mono text-sm font-black tracking-tighter uppercase">dk<span className="text-red-500">0</span>.dev</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<motion.a
|
</div>
|
||||||
href="#contact"
|
|
||||||
whileHover={{ scale: 1.03, y: -2 }}
|
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
|
||||||
className="px-8 py-4 bg-white text-stone-900 border border-stone-200 rounded-full font-medium shadow-sm hover:shadow-md transition-all duration-500"
|
|
||||||
>
|
|
||||||
<span>{t("ctaContact")}</span>
|
|
||||||
</motion.a>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, 15, 0] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
className="absolute bottom-10 left-1/2 -translate-x-1/2 hidden md:flex flex-col items-center gap-4"
|
||||||
|
>
|
||||||
|
<div className="w-px h-16 bg-gradient-to-b from-stone-300 dark:from-stone-700 to-transparent" />
|
||||||
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { motion, Variants } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
const fadeInUp: Variants = {
|
|
||||||
hidden: { opacity: 0, y: 20 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
transition: {
|
|
||||||
duration: 0.5,
|
|
||||||
ease: [0.25, 0.1, 0.25, 1],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const staggerContainer: Variants = {
|
|
||||||
hidden: { opacity: 0 },
|
|
||||||
visible: {
|
|
||||||
opacity: 1,
|
|
||||||
transition: {
|
|
||||||
staggerChildren: 0.2,
|
|
||||||
delayChildren: 0.1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -47,215 +25,105 @@ interface Project {
|
|||||||
|
|
||||||
const Projects = () => {
|
const Projects = () => {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const t = useTranslations("home.projects");
|
useTranslations("home.projects");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
|
||||||
"/api/projects?featured=true&published=true&limit=6",
|
|
||||||
);
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setProjects(data.projects || []);
|
setProjects(data.projects || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === "development") {
|
console.error("Featured projects fetch failed:", error);
|
||||||
console.error("Error loading projects:", error);
|
} finally {
|
||||||
}
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadProjects();
|
loadProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section id="projects" className="py-32 px-4 bg-stone-50 dark:bg-stone-950">
|
||||||
id="projects"
|
|
||||||
className="py-24 px-4 relative bg-gradient-to-br from-liquid-peach/15 via-liquid-yellow/10 to-liquid-coral/15 overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<motion.div
|
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
|
||||||
initial="hidden"
|
<div>
|
||||||
whileInView="visible"
|
<h2 className="text-4xl md:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-4 uppercase">
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
Selected Work<span className="text-liquid-mint">.</span>
|
||||||
variants={fadeInUp}
|
</h2>
|
||||||
className="text-center mb-20"
|
<p className="text-xl text-stone-500 max-w-xl font-light">
|
||||||
>
|
Projects that pushed my boundaries.
|
||||||
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
|
</p>
|
||||||
{t("title")}
|
</div>
|
||||||
</h2>
|
<Link href={`/${locale}/projects`} className="group flex items-center gap-2 text-stone-900 dark:text-stone-100 font-black border-b-2 border-stone-900 dark:border-stone-100 pb-1 hover:opacity-70 transition-all text-xs uppercase tracking-widest">
|
||||||
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light">
|
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
||||||
{t("subtitle")}
|
</Link>
|
||||||
</p>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||||
initial="hidden"
|
{loading ? (
|
||||||
whileInView="visible"
|
Array.from({ length: 2 }).map((_, i) => (
|
||||||
viewport={{ once: true, margin: "-50px" }}
|
<div key={i} className="space-y-6">
|
||||||
variants={staggerContainer}
|
<Skeleton className="aspect-[4/3] rounded-[2.5rem]" />
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
<div className="space-y-3">
|
||||||
>
|
<Skeleton className="h-8 w-1/2" />
|
||||||
{projects.map((project) => (
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
projects.map((project) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
variants={fadeInUp}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
whileHover={{ y: -8 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-[box-shadow,border-color,background-color] duration-500"
|
viewport={{ once: true }}
|
||||||
|
className="group relative"
|
||||||
>
|
>
|
||||||
{/* Project Cover / Image Area */}
|
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
||||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
|
{/* Image Card */}
|
||||||
{project.imageUrl ? (
|
<div className="relative aspect-[4/3] rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-6">
|
||||||
<>
|
{project.imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={project.imageUrl}
|
src={project.imageUrl}
|
||||||
alt={project.title}
|
alt={project.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover transition-transform duration-1000 ease-out group-hover:scale-110"
|
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
|
) : (
|
||||||
</>
|
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-stone-100 to-stone-200 dark:from-stone-800 dark:to-stone-900">
|
||||||
) : (
|
<span className="text-4xl font-bold text-stone-300 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||||
<div className="absolute inset-0 bg-stone-200 flex items-center justify-center overflow-hidden">
|
</div>
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-stone-300 via-stone-200 to-stone-300" />
|
)}
|
||||||
<div className="absolute top-[-20%] left-[-10%] w-[70%] h-[70%] bg-white/20 rounded-full blur-3xl animate-pulse" />
|
{/* Overlay on Hover */}
|
||||||
<div className="absolute bottom-[-10%] right-[-5%] w-[60%] h-[60%] bg-stone-400/10 rounded-full blur-2xl" />
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
|
||||||
|
</div>
|
||||||
<div className="relative z-10">
|
|
||||||
<span className="text-7xl font-serif font-black text-stone-800/10 group-hover:text-stone-800/20 transition-all duration-700 select-none tracking-tighter">
|
{/* Text Content */}
|
||||||
{project.title.charAt(0)}
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-stone-900 dark:text-stone-100 mb-2 group-hover:underline decoration-2 underline-offset-4">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 line-clamp-2 max-w-md">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:flex gap-2">
|
||||||
|
{project.tags.slice(0, 2).map(tag => (
|
||||||
|
<span key={tag} className="px-3 py-1 rounded-full border border-stone-200 dark:border-stone-800 text-xs font-medium text-stone-600 dark:text-stone-400">
|
||||||
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
))}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Texture/Grain Overlay */}
|
|
||||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
|
||||||
|
|
||||||
{/* Animated Shine Effect */}
|
|
||||||
<div className="absolute inset-0 translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000 ease-in-out bg-gradient-to-r from-transparent via-white/20 to-transparent skew-x-[-20deg] pointer-events-none" />
|
|
||||||
|
|
||||||
{/* Featured Badge */}
|
|
||||||
{project.featured && (
|
|
||||||
<div className="absolute top-3 left-3 z-20">
|
|
||||||
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
|
|
||||||
{t("featured")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Overlay Links */}
|
|
||||||
<div className="absolute inset-0 bg-stone-900/40 opacity-0 group-hover:opacity-100 transition-opacity duration-500 ease-out flex items-center justify-center gap-4 backdrop-blur-[2px] z-20 pointer-events-none">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="GitHub"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="p-3 bg-white text-stone-900 rounded-full hover:scale-110 transition-all duration-300 shadow-xl border border-white/50 pointer-events-auto"
|
|
||||||
aria-label="Live Demo"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={20} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6 flex flex-col flex-1">
|
|
||||||
{/* Stretched Link covering the whole card (including image area) */}
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/projects/${project.slug}`}
|
|
||||||
className="absolute inset-0 z-10"
|
|
||||||
aria-label={`View project ${project.title}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<h3 className="text-xl font-bold text-stone-900 group-hover:text-stone-600 transition-colors">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center space-x-2 text-stone-400 text-xs font-mono bg-white/50 px-2 py-1 rounded border border-stone-100">
|
|
||||||
<Calendar size={12} />
|
|
||||||
<span>{new Date(project.date).getFullYear()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<p className="text-stone-600 mb-6 leading-relaxed line-clamp-3 text-sm flex-1">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{project.tags.slice(0, 4).map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="px-2.5 py-1 bg-white/60 border border-stone-100 text-stone-600 text-xs font-medium rounded-md"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{project.tags.length > 4 && (
|
|
||||||
<span className="px-2 py-1 text-stone-400 text-xs">+ {project.tags.length - 4}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-auto pt-4 border-t border-stone-100 flex items-center justify-between relative z-20">
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{project.github && (
|
|
||||||
<a
|
|
||||||
href={project.github}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Github size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{project.live && !project.title.toLowerCase().includes('kernel panic') && (
|
|
||||||
<a
|
|
||||||
href={project.live}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-stone-400 hover:text-stone-900 transition-colors relative z-20 hover:scale-110"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ExternalLink size={18} />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
)))}
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
|
||||||
className="mt-16 text-center"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${locale}/projects`}
|
|
||||||
className="inline-flex items-center gap-2 px-8 py-4 bg-white border border-stone-200 rounded-full text-stone-700 font-medium hover:bg-stone-50 hover:border-stone-300 hover:gap-3 transition-all duration-500 ease-out shadow-sm hover:shadow-md"
|
|
||||||
>
|
|
||||||
{t("viewAll")} <ArrowRight size={16} />
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
241
app/components/ReadBooks.tsx
Normal file
241
app/components/ReadBooks.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { BookCheck, Star, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Skeleton } from "./ui/Skeleton";
|
||||||
|
|
||||||
|
interface BookReview {
|
||||||
|
id: string;
|
||||||
|
hardcover_id?: string;
|
||||||
|
book_title: string;
|
||||||
|
book_author: string;
|
||||||
|
book_image?: string;
|
||||||
|
rating?: number | null;
|
||||||
|
review?: string | null;
|
||||||
|
finished_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StarRating = ({ rating }: { rating: number }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Star
|
||||||
|
key={star}
|
||||||
|
size={14}
|
||||||
|
className={
|
||||||
|
star <= rating
|
||||||
|
? "text-amber-500 fill-amber-500"
|
||||||
|
: "text-stone-300 dark:text-stone-600"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripHtml = (html: string) => {
|
||||||
|
if (typeof window === 'undefined') return html; // Fallback for SSR
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
return doc.body.textContent || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReadBooks = () => {
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations("home.about.readBooks");
|
||||||
|
const [reviews, setReviews] = useState<BookReview[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const INITIAL_SHOW = 3;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchReviews = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/book-reviews?locale=${encodeURIComponent(locale)}`,
|
||||||
|
{ cache: "default" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.bookReviews) {
|
||||||
|
setReviews(data.bookReviews);
|
||||||
|
} else {
|
||||||
|
setReviews([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error("Error fetching book reviews:", error);
|
||||||
|
}
|
||||||
|
setReviews([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchReviews();
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
<Skeleton className="w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2 w-full">
|
||||||
|
<Skeleton className="h-5 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-1/3" />
|
||||||
|
<Skeleton className="h-3 w-1/4 pt-2" />
|
||||||
|
<Skeleton className="h-12 w-full pt-2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviews.length === 0) {
|
||||||
|
return null; // Hier kannst du temporär "Keine Bücher gefunden" reinschreiben zum Testen
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleReviews = expanded ? reviews : reviews.slice(0, INITIAL_SHOW);
|
||||||
|
const hasMore = reviews.length > INITIAL_SHOW;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<BookCheck size={18} className="text-stone-600 dark:text-stone-300 flex-shrink-0" />
|
||||||
|
<h3 className="text-lg font-bold text-stone-900 dark:text-stone-100">
|
||||||
|
{t("title")} ({reviews.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Book Reviews */}
|
||||||
|
{visibleReviews.map((review, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={review.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-50px" }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.6,
|
||||||
|
delay: index * 0.1,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
}}
|
||||||
|
whileHover={{
|
||||||
|
scale: 1.02,
|
||||||
|
transition: { duration: 0.4, ease: "easeOut" },
|
||||||
|
}}
|
||||||
|
className="relative overflow-hidden bg-gradient-to-br from-liquid-mint/15 via-liquid-sky/10 to-liquid-teal/15 dark:from-stone-800 dark:via-stone-800 dark:to-stone-700 border-2 border-liquid-mint/30 dark:border-stone-700 rounded-xl p-5 backdrop-blur-sm hover:border-liquid-mint/50 dark:hover:border-stone-600 hover:from-liquid-mint/20 hover:via-liquid-sky/15 hover:to-liquid-teal/20 transition-all duration-500 ease-out"
|
||||||
|
>
|
||||||
|
{/* Background Blob */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute -bottom-8 -left-8 w-28 h-28 bg-gradient-to-br from-liquid-mint/20 to-liquid-sky/20 dark:from-stone-700 dark:to-stone-600 rounded-full blur-2xl"
|
||||||
|
animate={{
|
||||||
|
scale: [1, 1.15, 1],
|
||||||
|
opacity: [0.3, 0.45, 0.3],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 8,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut",
|
||||||
|
delay: index * 0.5,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col sm:flex-row gap-4 items-start">
|
||||||
|
{/* Book Cover */}
|
||||||
|
{review.book_image && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<div className="relative w-20 h-[7.5rem] sm:w-24 sm:h-32 rounded-lg overflow-hidden shadow-lg border-2 border-white/50 dark:border-stone-600">
|
||||||
|
<Image
|
||||||
|
src={review.book_image}
|
||||||
|
alt={review.book_title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 80px, 96px"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-white/20 via-transparent to-white/10 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Book Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-base font-bold text-stone-900 dark:text-stone-100 mb-0.5 line-clamp-2">
|
||||||
|
{review.book_title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-stone-600 dark:text-stone-400 mb-2 line-clamp-1">
|
||||||
|
{review.book_author}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Rating (Optional) */}
|
||||||
|
{review.rating && review.rating > 0 && (
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<StarRating rating={review.rating} />
|
||||||
|
<span className="text-xs text-stone-500 dark:text-stone-400 font-medium">
|
||||||
|
{review.rating}/5
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Text (Optional) */}
|
||||||
|
{review.review && (
|
||||||
|
<p className="text-sm text-stone-700 dark:text-stone-300 leading-relaxed line-clamp-3 italic">
|
||||||
|
“{stripHtml(review.review)}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Finished Date */}
|
||||||
|
{review.finished_at && (
|
||||||
|
<p className="text-xs text-stone-400 dark:text-stone-500 mt-2">
|
||||||
|
{t("finishedAt")}{" "}
|
||||||
|
{new Date(review.finished_at).toLocaleDateString(
|
||||||
|
locale === "de" ? "de-DE" : "en-US",
|
||||||
|
{ year: "numeric", month: "short" }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Show More / Show Less */}
|
||||||
|
{hasMore && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 py-2.5 text-sm font-medium text-stone-600 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200 rounded-lg border-2 border-dashed border-stone-200 dark:border-stone-700 hover:border-stone-300 dark:hover:border-stone-600 transition-colors duration-300"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
{t("showLess")} <ChevronUp size={16} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{t("showMore", { count: reviews.length - INITIAL_SHOW })}{" "}
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReadBooks;
|
||||||
11
app/components/ThemeProvider.tsx
Normal file
11
app/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
35
app/components/ThemeToggle.tsx
Normal file
35
app/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return <div className="w-9 h-9" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
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"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun size={18} className="text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Moon size={18} className="text-stone-600" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/components/ui/BentoGrid.tsx
Normal file
60
app/components/ui/BentoGrid.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export const BentoGrid = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid md:auto-rows-[18rem] grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto ",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BentoGridItem = ({
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
header,
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
title?: string | React.ReactNode;
|
||||||
|
description?: string | React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ y: -5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className={cn(
|
||||||
|
"row-span-1 rounded-3xl group/bento hover:shadow-xl transition duration-200 shadow-input dark:shadow-none p-4 dark:bg-stone-900 bg-white border border-stone-200 dark:border-stone-800 justify-between flex flex-col space-y-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="group-hover/bento:translate-x-2 transition duration-200">
|
||||||
|
{icon}
|
||||||
|
<div className="font-sans font-bold text-stone-800 dark:text-stone-100 mb-2 mt-2">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
<div className="font-sans font-normal text-stone-600 dark:text-stone-400 text-xs">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
app/components/ui/Skeleton.tsx
Normal file
16
app/components/ui/Skeleton.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"animate-pulse rounded-md bg-stone-200/50 dark:bg-stone-800/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,20 @@
|
|||||||
@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 */
|
||||||
--background: #faf8f3; /* Warm off-white */
|
--background: #faf8f3; /* Warm off-white */
|
||||||
@@ -26,8 +40,30 @@
|
|||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #1c1917; /* stone-900 */
|
||||||
|
--foreground: #f5f5f4; /* stone-100 */
|
||||||
|
--card: rgba(28, 25, 23, 0.7);
|
||||||
|
--card-foreground: #f5f5f4;
|
||||||
|
--popover: #1c1917;
|
||||||
|
--popover-foreground: #f5f5f4;
|
||||||
|
--primary: #d6d3d1; /* stone-300 */
|
||||||
|
--primary-foreground: #1c1917;
|
||||||
|
--secondary: #44403c; /* stone-700 */
|
||||||
|
--secondary-foreground: #f5f5f4;
|
||||||
|
--muted: #292524; /* stone-800 */
|
||||||
|
--muted-foreground: #a8a29e; /* stone-400 */
|
||||||
|
--accent: #57534e; /* stone-600 */
|
||||||
|
--accent-foreground: #f5f5f4;
|
||||||
|
--destructive: #7f1d1d; /* dark red */
|
||||||
|
--destructive-foreground: #f5f5f4;
|
||||||
|
--border: #44403c;
|
||||||
|
--input: #292524;
|
||||||
|
--ring: #d6d3d1;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: linear-gradient(135deg, rgba(250, 248, 243, 0.95) 0%, rgba(250, 248, 243, 0.92) 100%);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
font-family: "Inter", sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -37,6 +73,7 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Selection */
|
/* Custom Selection */
|
||||||
@@ -52,35 +89,33 @@ html {
|
|||||||
|
|
||||||
/* Liquid Glass Effects */
|
/* Liquid Glass Effects */
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
background: rgba(250, 248, 243, 0.75);
|
background: var(--card);
|
||||||
backdrop-filter: blur(20px) saturate(130%);
|
backdrop-filter: blur(20px) saturate(130%);
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(130%);
|
-webkit-backdrop-filter: blur(20px) saturate(130%);
|
||||||
border: 1px solid rgba(215, 204, 200, 0.6);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 8px 32px rgba(62, 39, 35, 0.12);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
will-change: backdrop-filter;
|
will-change: backdrop-filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
background: rgba(255, 252, 245, 0.85);
|
background: var(--card);
|
||||||
backdrop-filter: blur(30px) saturate(200%);
|
backdrop-filter: blur(30px) saturate(200%);
|
||||||
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
-webkit-backdrop-filter: blur(30px) saturate(200%);
|
||||||
border: 1px solid rgba(215, 204, 200, 0.7);
|
border: 1px solid var(--border);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 6px -1px rgba(62, 39, 35, 0.06),
|
0 4px 6px -1px rgba(0, 0, 0, 0.06),
|
||||||
0 2px 4px -1px rgba(62, 39, 35, 0.05),
|
0 2px 4px -1px rgba(0, 0, 0, 0.05);
|
||||||
inset 0 0 30px rgba(255, 252, 245, 0.6);
|
|
||||||
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
|
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||||
will-change: transform, box-shadow;
|
will-change: transform, box-shadow;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card:hover {
|
.glass-card:hover {
|
||||||
background: rgba(255, 252, 245, 0.95);
|
background: var(--card);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 25px -5px rgba(62, 39, 35, 0.15),
|
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||||
0 10px 10px -5px rgba(62, 39, 35, 0.08),
|
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||||
inset 0 0 30px rgba(255, 252, 245, 0.9);
|
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
border-color: rgba(215, 204, 200, 0.9);
|
border-color: var(--ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography & Headings */
|
/* Typography & Headings */
|
||||||
@@ -93,7 +128,7 @@ h6 {
|
|||||||
font-family: var(--font-playfair), Georgia, serif;
|
font-family: var(--font-playfair), Georgia, serif;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Improve text contrast - using foreground variable for WCAG AA compliance */
|
/* Improve text contrast - using foreground variable for WCAG AA compliance */
|
||||||
@@ -154,34 +189,34 @@ div {
|
|||||||
/* Markdown Specifics for Blog/Projects */
|
/* Markdown Specifics for Blog/Projects */
|
||||||
.markdown h1 {
|
.markdown h1 {
|
||||||
@apply text-4xl font-bold mb-6 tracking-tight;
|
@apply text-4xl font-bold mb-6 tracking-tight;
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown h2 {
|
.markdown h2 {
|
||||||
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
|
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown p {
|
.markdown p {
|
||||||
@apply mb-4 leading-relaxed;
|
@apply mb-4 leading-relaxed;
|
||||||
color: #4e342e;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown a {
|
.markdown a {
|
||||||
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
|
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
|
||||||
color: #5d4037;
|
color: var(--primary);
|
||||||
text-decoration-color: #a1887f;
|
text-decoration-color: var(--accent);
|
||||||
}
|
}
|
||||||
.markdown ul {
|
.markdown ul {
|
||||||
@apply list-disc list-inside mb-4 space-y-2;
|
@apply list-disc list-inside mb-4 space-y-2;
|
||||||
color: #4e342e;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown code {
|
.markdown code {
|
||||||
@apply px-1.5 py-0.5 rounded text-sm font-mono;
|
@apply px-1.5 py-0.5 rounded text-sm font-mono;
|
||||||
background: #efebe9;
|
background: var(--muted);
|
||||||
color: #3e2723;
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.markdown pre {
|
.markdown pre {
|
||||||
@apply p-4 rounded-xl overflow-x-auto mb-6;
|
@apply p-4 rounded-xl overflow-x-auto mb-6;
|
||||||
background: #3e2723;
|
background: var(--foreground);
|
||||||
color: #faf8f3;
|
color: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Admin Dashboard Styles - Warm Brown Theme */
|
/* Admin Dashboard Styles - Warm Brown Theme */
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export default async function RootLayout({
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
|
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
|
||||||
return (
|
return (
|
||||||
<html lang={locale}>
|
<html lang={locale} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||||
|
<div className="grain-overlay" aria-hidden="true" />
|
||||||
<ShaderGradientBackground />
|
<ShaderGradientBackground />
|
||||||
<ClientProviders>{children}</ClientProviders>
|
<ClientProviders>{children}</ClientProviders>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft } 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";
|
||||||
@@ -15,7 +15,6 @@ 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 [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||||
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -24,114 +23,120 @@ export default function LegalNotice() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Only use CMS content if it exists for the active locale.
|
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.content && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsDoc(data.content.content as JSONContent);
|
||||||
setCmsTitle((data.content.title as string | null) ?? null);
|
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore; fallback to static content
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen animated-bg">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 pt-32 pb-20">
|
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||||
|
|
||||||
|
{/* Editorial Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
className="mb-20"
|
||||||
className="mb-8"
|
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>{t("backToHome")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
{cmsTitle || "Impressum"}
|
Legal<span className="text-liquid-mint">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
{/* Bento Content Grid */}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
{/* Main Legal Content (Large Box) */}
|
||||||
className="glass-card p-8 rounded-2xl space-y-6"
|
<motion.div
|
||||||
>
|
initial={{ opacity: 0, y: 30 }}
|
||||||
{cmsDoc ? (
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
|
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"
|
||||||
<>
|
>
|
||||||
<div className="text-gray-300 leading-relaxed">
|
{cmsDoc ? (
|
||||||
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Inhalte dieser Website</h2>
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
<div className="space-y-2 text-gray-300">
|
<RichTextClient doc={cmsDoc} />
|
||||||
<p>
|
</div>
|
||||||
<strong>Name:</strong> Dennis Konkol
|
) : (
|
||||||
</p>
|
<div className="space-y-16">
|
||||||
<p>
|
<section>
|
||||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
</p>
|
<Scale className="text-liquid-mint" size={28} /> Angaben gemäß § 5 TMG
|
||||||
<p>
|
</h2>
|
||||||
<strong>E-Mail:</strong>{" "}
|
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-4">
|
||||||
<Link href="mailto:info@dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">
|
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
|
||||||
info@dk0.dev
|
<p>Auf dem Ziegenbrink 2B</p>
|
||||||
</Link>
|
<p>49082 Osnabrück, Deutschland</p>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
</section>
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<Link href="https://www.dk0.dev" className="text-blue-400 hover:text-blue-300 transition-colors">
|
<section>
|
||||||
dk0.dev
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
</Link>
|
<ShieldCheck className="text-liquid-sky" size={28} /> Haftungsausschluss
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Die Inhalte dieser Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte kann ich jedoch keine Gewähr übernehmen.
|
||||||
</p>
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Sidebar Widgets */}
|
||||||
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|
||||||
|
{/* Quick Contact Box */}
|
||||||
|
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
|
||||||
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Direct Contact</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
|
||||||
|
<Mail className="text-liquid-mint" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Email</p>
|
||||||
|
<Link href="mailto:info@dk0.dev" className="font-bold hover:text-liquid-mint transition-colors">info@dk0.dev</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-white/10 flex items-center justify-center border border-white/10">
|
||||||
|
<MapPin className="text-liquid-sky" size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-widest text-stone-500">Location</p>
|
||||||
|
<p className="font-bold">Osnabrück, GER</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-300">
|
{/* Meta Info Box */}
|
||||||
<h2 className="text-2xl font-semibold mb-4">Haftung für Links</h2>
|
<div className="bg-liquid-purple/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-purple/20 dark:border-stone-800/60">
|
||||||
<p className="leading-relaxed">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser
|
<Clock className="text-liquid-purple" size={20} />
|
||||||
Websites und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der
|
<div>
|
||||||
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum
|
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p>
|
||||||
Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde
|
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">February 15, 2025</p>
|
||||||
ich derartige Links umgehend entfernen.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-stone-500 leading-relaxed">
|
||||||
|
This legal notice applies to all contents on dk0.dev and related social media profiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="text-gray-300">
|
</div>
|
||||||
<h2 className="text-2xl font-semibold mb-4">Urheberrecht</h2>
|
</div>
|
||||||
<p className="leading-relaxed">
|
|
||||||
Alle Inhalte dieser Website, einschließlich Texte, Fotos und Designs, stehen unter
|
|
||||||
Urheberrechtsschutz. Jegliche Nutzung ohne vorherige schriftliche Zustimmung des Urhebers ist
|
|
||||||
verboten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-gray-300">
|
|
||||||
<h2 className="text-2xl font-semibold mb-4">Gewährleistung</h2>
|
|
||||||
<p className="leading-relaxed">
|
|
||||||
Die Nutzung der Inhalte dieser Website erfolgt auf eigene Gefahr. Als Diensteanbieter kann ich keine
|
|
||||||
Gewähr übernehmen für Schäden, die entstehen können, durch den Zugriff oder die Nutzung dieser
|
|
||||||
Website.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t border-gray-700">
|
|
||||||
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,150 +1,109 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { motion } from "framer-motion";
|
||||||
|
import { ArrowLeft, Search, Terminal } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Home, ArrowLeft, Search } from "lucide-react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [input, setInput] = useState("");
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
if (!mounted) return null;
|
||||||
if (process.env.NODE_ENV === "test") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
Oops! The page you're looking for doesn't exist.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3]">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-[#795548]">Loading...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCommand = (cmd: string) => {
|
|
||||||
const command = cmd.toLowerCase().trim();
|
|
||||||
if (command === 'home' || command === 'cd ~' || command === 'cd /') {
|
|
||||||
router.push('/');
|
|
||||||
} else if (command === 'back' || command === 'cd ..') {
|
|
||||||
router.back();
|
|
||||||
} else if (command === 'search') {
|
|
||||||
router.push('/projects');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3] p-4">
|
<main className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 py-24 px-6 flex items-center justify-center transition-colors duration-500">
|
||||||
<div className="w-full max-w-2xl">
|
<div className="max-w-7xl mx-auto w-full">
|
||||||
{/* Terminal-style 404 */}
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8 max-w-5xl mx-auto">
|
||||||
<div className="bg-[#3e2723] rounded-2xl shadow-2xl overflow-hidden border border-[#5d4037]">
|
|
||||||
{/* Terminal Header */}
|
{/* Main Error Card */}
|
||||||
<div className="bg-[#5d4037] px-4 py-3 flex items-center gap-2 border-b border-[#795548]">
|
<motion.div
|
||||||
<div className="flex gap-2">
|
initial={{ opacity: 0, y: 30 }}
|
||||||
<div className="w-3 h-3 rounded-full bg-[#d84315]"></div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<div className="w-3 h-3 rounded-full bg-[#bcaaa4]"></div>
|
className="md:col-span-12 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 flex flex-col justify-between min-h-[400px]"
|
||||||
<div className="w-3 h-3 rounded-full bg-[#a1887f]"></div>
|
>
|
||||||
</div>
|
<div>
|
||||||
<div className="ml-4 text-[#faf8f3] text-sm font-mono">
|
<div className="flex items-center gap-3 mb-12">
|
||||||
terminal@portfolio ~ 404
|
<div className="w-10 h-10 rounded-2xl bg-stone-900 dark:bg-stone-50 flex items-center justify-center text-white dark:text-stone-900 font-black text-xs">
|
||||||
</div>
|
404
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Terminal Body */}
|
|
||||||
<div className="p-6 md:p-8 font-mono text-sm md:text-base">
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="text-[#bcaaa4] mb-2">$ cd {mounted ? window.location.pathname : '/unknown'}</div>
|
|
||||||
<div className="text-[#d84315] mb-4">
|
|
||||||
<span className="mr-2">✗</span>
|
|
||||||
Error: ENOENT: no such file or directory
|
|
||||||
</div>
|
|
||||||
<div className="text-[#a1887f] mb-6">
|
|
||||||
<pre className="whitespace-pre-wrap">
|
|
||||||
{`
|
|
||||||
██╗ ██╗ ██████╗ ██╗ ██╗
|
|
||||||
██║ ██║██╔═████╗██║ ██║
|
|
||||||
███████║██║██╔██║███████║
|
|
||||||
╚════██║████╔╝██║╚════██║
|
|
||||||
██║╚██████╔╝ ██║
|
|
||||||
╚═╝ ╚═════╝ ╚═╝
|
|
||||||
`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[#faf8f3] mb-6">
|
|
||||||
<p className="mb-3">The page you're looking for seems to have wandered off.</p>
|
|
||||||
<p className="text-[#bcaaa4]">Perhaps it never existed, or maybe it's on a coffee break.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 text-[#a1887f]">
|
|
||||||
<div className="mb-2">Available commands:</div>
|
|
||||||
<div className="pl-4 space-y-1 text-sm">
|
|
||||||
<div>→ <span className="text-[#faf8f3]">home</span> - Return to homepage</div>
|
|
||||||
<div>→ <span className="text-[#faf8f3]">back</span> - Go back to previous page</div>
|
|
||||||
<div>→ <span className="text-[#faf8f3]">search</span> - Search the website</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-5xl md:text-8xl font-black tracking-tighter uppercase text-stone-900 dark:text-stone-50 leading-[0.8] mb-8">
|
||||||
|
Page not <br/>Found<span className="text-liquid-mint">.</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl font-light text-stone-500 max-w-md leading-relaxed">
|
||||||
|
The content you are looking for has been moved, deleted, or never existed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Interactive Command Line */}
|
<div className="mt-12 flex flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-2 border-t border-[#5d4037] pt-4">
|
<Link
|
||||||
<span className="text-[#a1887f]">$</span>
|
href="/"
|
||||||
<input
|
className="group relative px-10 py-4 bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 rounded-2xl font-black text-xs uppercase tracking-[0.2em] shadow-xl hover:scale-105 transition-all"
|
||||||
type="text"
|
>
|
||||||
value={input}
|
Return Home
|
||||||
onChange={(e) => setInput(e.target.value)}
|
</Link>
|
||||||
onKeyDown={(e) => {
|
<button
|
||||||
if (e.key === 'Enter') {
|
onClick={() => router.back()}
|
||||||
handleCommand(input);
|
className="px-10 py-4 bg-white dark:bg-stone-800 text-stone-900 dark:text-stone-100 border border-stone-200 dark:border-stone-700 rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-stone-50 dark:hover:bg-stone-700 transition-all"
|
||||||
setInput('');
|
>
|
||||||
}
|
Go Back
|
||||||
}}
|
</button>
|
||||||
placeholder="Type a command..."
|
|
||||||
className="flex-1 bg-transparent text-[#faf8f3] outline-none placeholder:text-[#795548] font-mono"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Sidebar Cards */}
|
||||||
|
<div className="md:col-span-12 lg:col-span-4 flex flex-col gap-6">
|
||||||
|
{/* Search/Explore Projects */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="bg-stone-900 rounded-[2.5rem] p-10 border border-stone-800 shadow-2xl text-white relative overflow-hidden group flex-1 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Search className="text-liquid-mint mb-6" size={32} />
|
||||||
|
<h3 className="text-2xl font-black uppercase tracking-tighter mb-2">Explore Work</h3>
|
||||||
|
<p className="text-stone-400 text-sm font-medium">Maybe what you need is in my project archive?</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/projects"
|
||||||
|
className="mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-liquid-mint group-hover:gap-4 transition-all"
|
||||||
|
>
|
||||||
|
View Projects <ArrowLeft className="rotate-180" size={14} />
|
||||||
|
</Link>
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-liquid-mint/5 blur-3xl rounded-full -mr-16 -mt-16" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Visit the Lab */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="bg-white dark:bg-stone-900 rounded-[2.5rem] p-10 border border-stone-200/60 dark:border-stone-800/60 shadow-sm flex-1 flex flex-col justify-between group"
|
||||||
|
>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<Terminal className="text-liquid-purple mb-6" size={32} />
|
||||||
|
<h3 className="text-2xl font-black uppercase tracking-tighter mb-2 text-stone-900 dark:text-stone-50">Technical</h3>
|
||||||
|
<p className="text-stone-500 dark:text-stone-400 text-sm font-medium">Check out my collection of code snippets and notes.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/snippets"
|
||||||
|
className="mt-8 inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Enter the Lab <ArrowLeft className="rotate-180" size={14} />
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Action Buttons */}
|
|
||||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
|
||||||
>
|
|
||||||
<Home className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
|
||||||
<span className="text-[#3e2723] font-medium">Home</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => router.back()}
|
|
||||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
|
||||||
<span className="text-[#3e2723] font-medium">Go Back</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/projects"
|
|
||||||
className="flex items-center justify-center gap-2 bg-[#fffcf5] hover:bg-[#faf8f3] border border-[#d7ccc8] rounded-xl px-6 py-4 transition-all hover:shadow-md group"
|
|
||||||
>
|
|
||||||
<Search className="w-5 h-5 text-[#5d4037] group-hover:text-[#3e2723]" />
|
|
||||||
<span className="text-[#3e2723] font-medium">Explore Projects</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ArrowLeft } 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";
|
||||||
@@ -15,7 +15,6 @@ 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 [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||||
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -24,310 +23,125 @@ export default function PrivacyPolicy() {
|
|||||||
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Only use CMS content if it exists for the active locale.
|
|
||||||
if (data?.content?.content && data?.content?.locale === locale) {
|
if (data?.content?.content && data?.content?.locale === locale) {
|
||||||
setCmsDoc(data.content.content as JSONContent);
|
setCmsDoc(data.content.content as JSONContent);
|
||||||
setCmsTitle((data.content.title as string | null) ?? null);
|
|
||||||
} else {
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// ignore; fallback to static content
|
|
||||||
setCmsDoc(null);
|
|
||||||
setCmsTitle(null);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen animated-bg">
|
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="max-w-4xl mx-auto px-4 pt-32 pb-20">
|
<main className="max-w-7xl mx-auto px-6 pt-40 pb-20">
|
||||||
|
|
||||||
|
{/* Editorial Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
className="mb-20"
|
||||||
className="mb-8"
|
|
||||||
>
|
>
|
||||||
<motion.a
|
<Link
|
||||||
href={`/${locale}`}
|
href={`/${locale}`}
|
||||||
className="inline-flex items-center space-x-2 text-blue-400 hover:text-blue-300 transition-colors mb-6"
|
className="inline-flex items-center gap-2 text-stone-500 hover:text-stone-900 dark:hover:text-white transition-colors mb-10 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>{t("backToHome")}</span>
|
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
|
||||||
</motion.a>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||||
{cmsTitle || "Datenschutzerklärung"}
|
Privacy<span className="text-liquid-purple">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
{/* Bento Content Grid */}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
{/* Main Privacy Text (Large) */}
|
||||||
className="glass-card p-8 rounded-2xl space-y-6 text-white"
|
<motion.div
|
||||||
>
|
initial={{ opacity: 0, y: 30 }}
|
||||||
{cmsDoc ? (
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
|
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"
|
||||||
<>
|
>
|
||||||
<div className="text-gray-300 leading-relaxed">
|
{cmsDoc ? (
|
||||||
<p>
|
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||||
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
|
<RichTextClient doc={cmsDoc} />
|
||||||
über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-16">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
|
<Shield className="text-liquid-mint" size={28} /> Allgemeiner Überblick
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
|
Der Schutz Ihrer persönlichen Daten ist mir ein besonderes Anliegen. Ich verarbeite Ihre Daten daher ausschließlich auf Grundlage der gesetzlichen Bestimmungen (DSGVO, TMG).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="text-gray-300 leading-relaxed">
|
<section>
|
||||||
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Datenverarbeitung</h2>
|
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||||
<div className="space-y-2 text-gray-300">
|
<Database className="text-liquid-sky" size={28} /> Datenerfassung
|
||||||
<p>
|
</h2>
|
||||||
<strong>Name:</strong> Dennis Konkol
|
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||||
</p>
|
Wenn Sie per Formular auf der Website oder per E-Mail Kontakt mit mir aufnehmen, werden Ihre angegebenen Daten zwecks Bearbeitung der Anfrage bei mir gespeichert.
|
||||||
<p>
|
|
||||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>E-Mail:</strong>{" "}
|
|
||||||
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dk0.dev">
|
|
||||||
info@dk0.dev
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Website:</strong>{" "}
|
|
||||||
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dk0.dev">
|
|
||||||
dk0.dev
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Quick Info Cards */}
|
||||||
|
<div className="lg:col-span-4 space-y-8">
|
||||||
|
|
||||||
|
{/* Core Values Box */}
|
||||||
|
<div className="bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white">
|
||||||
|
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-purple">Principles</h3>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Lock className="text-liquid-mint mt-1" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Encryption</p>
|
||||||
|
<p className="text-xs text-stone-500">SSL/TLS secured data transfer.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Eye className="text-liquid-sky mt-1" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Transparency</p>
|
||||||
|
<p className="text-xs text-stone-500">No hidden tracking algorithms.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Globe className="text-liquid-purple mt-1" size={18} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold">Compliance</p>
|
||||||
|
<p className="text-xs text-stone-500">GDPR / DSGVO optimized.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4">
|
|
||||||
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
|
|
||||||
Verantwortlichen.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
{/* Cookie Status Indicator */}
|
||||||
Erfassung allgemeiner Informationen beim Besuch meiner Website
|
<div className="bg-liquid-mint/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-mint/20 dark:border-stone-800/60">
|
||||||
</h2>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="mt-2">
|
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
Beim Zugriff auf meiner Website werden automatisch Informationen allgemeiner Natur erfasst. Diese
|
<p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p>
|
||||||
beinhalten unter anderem:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>IP-Adresse (in anonymisierter Form)</li>
|
|
||||||
<li>Uhrzeit</li>
|
|
||||||
<li>Browsertyp</li>
|
|
||||||
<li>Verwendetes Betriebssystem</li>
|
|
||||||
<li>Referrer-URL (die zuvor besuchte Seite)</li>
|
|
||||||
</ul>
|
|
||||||
<br />
|
|
||||||
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
|
|
||||||
Rückschlüsse auf Ihre Person sind nicht möglich. Diese Daten werden verarbeitet, um:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>die Inhalte meiner Website korrekt auszuliefern,</li>
|
|
||||||
<li>die Inhalte meiner Website zu optimieren,</li>
|
|
||||||
<li>die Systemsicherheit und -stabilität zu analysiern.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('cookie-consent');
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
className="text-[10px] font-black uppercase tracking-widest border-b border-stone-300 dark:border-stone-700 pb-1 hover:text-liquid-mint transition-colors"
|
||||||
|
>
|
||||||
|
Reset Privacy Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Cookies</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Diese Website verwendet ein technisch notwendiges Cookie, um deine Datenschutz-Einstellungen (z.B.
|
|
||||||
Analytics/Chatbot) zu speichern. Ohne dieses Cookie wäre ein Consent-Banner bei jedem Besuch erneut
|
|
||||||
nötig.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Analyse- und Tracking-Tools</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Die nachfolgend beschriebene Analyse- und Tracking-Methode (im
|
|
||||||
Folgenden „Maßnahme“ genannt) basiert auf Art. 6 Abs. 1 S. 1 lit. f
|
|
||||||
DSGVO. Durch diese Maßnahme möchten ich eine benutzerfreundliche
|
|
||||||
Gestaltung sowie eine kontinuierliche Verbesserung meiner Website
|
|
||||||
sicherstellen. Diese Interessen sind im Sinne der genannten Vorschrift
|
|
||||||
als berechtigt anzusehen.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes
|
|
||||||
Interesse an der Analyse und Optimierung unserer Website).
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Detaillierte Informationen zu den erhobenen Daten und deren
|
|
||||||
Verarbeitung finden Sie in den nachfolgenden Abschnitten.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Zur Analyse der Nutzung meiner Website setze ich Umami ein. Umami
|
|
||||||
speichert keine IP-Adressen oder Cookies. Alle erfassten Daten sind
|
|
||||||
anonymisiert. Da ich Umami auf meinem eigenen Server betreibe, erfolgt
|
|
||||||
keine Weitergabe an Dritte. Weitere Informationen finden Sie unter{" "}
|
|
||||||
<Link
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
href={"https://umami.is"}
|
|
||||||
>
|
|
||||||
Umami
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4">
|
|
||||||
Zusätzlich kann diese Website optionale, selbst gehostete
|
|
||||||
Nutzungsstatistiken erfassen (z.B. Seitenaufrufe, Performance-Metriken),
|
|
||||||
die erst nach deiner Einwilligung im Consent-Banner aktiviert werden.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Error Monitoring (Sentry)</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Um Fehler und Probleme auf dieser Website schnell zu erkennen und zu beheben,
|
|
||||||
nutze ich Sentry.io, einen Dienst zur Fehlerüberwachung. Dabei werden technische
|
|
||||||
Informationen wie Browser-Typ, Betriebssystem, URL der aufgerufenen Seite und
|
|
||||||
Fehlermeldungen an Sentry übermittelt. Diese Daten dienen ausschließlich der
|
|
||||||
Verbesserung der Website-Stabilität und werden nicht für andere Zwecke verwendet.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Anbieter: Functional Software, Inc. (Sentry), 45 Fremont Street, 8th Floor,
|
|
||||||
San Francisco, CA 94105, USA
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. f DSGVO (berechtigtes Interesse an
|
|
||||||
der Fehleranalyse und Systemstabilität).
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Weitere Informationen: <Link
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
href={"https://sentry.io/privacy/"}
|
|
||||||
>
|
|
||||||
Sentry Datenschutzerklärung
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Kontaktformular</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Wenn Sie das Kontaktformular nutzen, werden Ihre Angaben zur
|
|
||||||
Bearbeitung Ihrer Anfrage gespeichert. Diese Daten werden nicht an
|
|
||||||
Dritte weitergegeben und nach Erfüllung des Zwecks gelöscht. <br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung).
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Chatbot</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Wenn du den optionalen Chatbot nutzt, werden die von dir eingegebenen
|
|
||||||
Nachrichten verarbeitet, um eine Antwort zu generieren. Die Verarbeitung
|
|
||||||
kann dabei über eine selbst gehostete Automations-/Chat-Infrastruktur
|
|
||||||
(z.B. n8n) erfolgen. Bitte gib im Chat keine sensiblen Daten ein.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
Rechtsgrundlage: Art. 6 Abs. 1 S. 1 lit. a DSGVO (Einwilligung) – der
|
|
||||||
Chatbot wird erst nach Aktivierung im Consent-Banner geladen.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Social Media Links</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Unsere Website enthält Links zu GitHub und LinkedIn. Durch das
|
|
||||||
Anklicken dieser Links gelten die Datenschutzbestimmungen der
|
|
||||||
jeweiligen Anbieter.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Weitergabe von Daten</h2>
|
|
||||||
<div className="mt-2">
|
|
||||||
Eine Weitergabe Ihrer personenbezogenen Daten erfolgt nur, wenn:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>
|
|
||||||
Sie nach Art. 6 Abs. 1 S. 1 lit. a DSGVO ausdrücklich eingewilligt
|
|
||||||
haben,
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
dies zur Vertragserfüllung gemäß Art. 6 Abs. 1 S. 1 lit. b DSGVO
|
|
||||||
erforderlich ist,
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
eine gesetzliche Verpflichtung zur Weitergabe nach Art. 6 Abs. 1
|
|
||||||
S. 1 lit. c DSGVO besteht oder
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
die Verarbeitung nach Art. 6 Abs. 1 S. 1 lit. f DSGVO zur Wahrung
|
|
||||||
berechtigter Interessen erforderlich ist.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
|
||||||
Speicherdauer und Löschung
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Ihre Daten werden nur solange gespeichert, wie dies für die Erfüllung
|
|
||||||
des Verarbeitungszwecks erforderlich ist. Nach Erfüllung des Zwecks
|
|
||||||
werden Ihre Daten gelöscht.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Ihre Rechte</h2>
|
|
||||||
<div className="mt-2">
|
|
||||||
Sie haben gemäß DSGVO folgende Rechte:
|
|
||||||
<ul className="list-disc list-inside mt-2">
|
|
||||||
<li>
|
|
||||||
Art. 15 DSGVO: Auskunftsrecht über Ihre von mir gespeicherten
|
|
||||||
Daten
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Art. 16 DSGVO: Recht auf Berichtigung unrichtiger oder
|
|
||||||
unvollständiger Daten
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Art. 17 DSGVO: Recht auf Löschung Ihrer bei mir gespeicherten
|
|
||||||
Daten (soweit keine gesetzlichen Aufbewahrungspflichten
|
|
||||||
entgegenstehen)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Art. 18 DSGVO: Recht auf Einschränkung der Verarbeitung Ihrer
|
|
||||||
Daten
|
|
||||||
</li>
|
|
||||||
<li>Art. 20 DSGVO: Recht auf Datenübertragbarkeit</li>
|
|
||||||
<li>
|
|
||||||
Art. 21 DSGVO: Widerspruchsrecht gegen die Verarbeitung Ihrer
|
|
||||||
Daten
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<br />
|
|
||||||
Falls Sie eine Einwilligung erklärt haben, können Sie diese jederzeit
|
|
||||||
widerrufen.
|
|
||||||
<br />
|
|
||||||
Beschwerden können Sie an die zuständige Datenschutzaufsichtsbehörde
|
|
||||||
richten. Eine Liste der Datenschutzbeauftragten sowie deren
|
|
||||||
Kontaktdaten finden Sie unter:{" "}
|
|
||||||
<Link
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
href={"https://www.bfdi.bund.de/"}
|
|
||||||
>
|
|
||||||
https://www.bfdi.bund.de/
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Datensicherheit</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Ich setze technische und organisatorische Maßnahmen ein, um Ihre Daten
|
|
||||||
zu schützen. Dazu gehören unter anderem die SSL-Verschlüsselung. Diese
|
|
||||||
Verschlüsselung erkennen Sie an dem Schloss-Symbol in der Adresszeile
|
|
||||||
Ihres Browsers und an der URL, die mit "https://" beginnt.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">Kontakt</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Bei Fragen zur Datenschutzerklärung kontaktieren Sie mich unter{" "}
|
|
||||||
<Link
|
|
||||||
href="mailto:info@dk0.dev"
|
|
||||||
className="text-blue-700 transition-underline"
|
|
||||||
>
|
|
||||||
info@dk0.dev
|
|
||||||
</Link>{" "}
|
|
||||||
oder nutzen Sie das Kontaktformular auf meiner Website.
|
|
||||||
</p>
|
|
||||||
<h2 className="text-2xl font-semibold mt-6">
|
|
||||||
Änderungen der Datenschutzerklärung
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2">
|
|
||||||
Diese Datenschutzerklärung wird regelmäßig aktualisiert, um den
|
|
||||||
gesetzlichen Anforderungen zu entsprechen und neue Entwicklungen zu
|
|
||||||
berücksichtigen. Die jeweils aktuelle Datenschutzerklärung finden Sie
|
|
||||||
auf meiner Website.
|
|
||||||
</p>
|
|
||||||
<div className="pt-4 border-t border-gray-700">
|
|
||||||
<p className="text-gray-400 text-sm">Letzte Aktualisierung: 12.02.2025</p>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
33
docs/DESIGN_OVERHAUL_LOG.md
Normal file
33
docs/DESIGN_OVERHAUL_LOG.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Design Overhaul Log: Editorial Bento Transformation (Feb 2026)
|
||||||
|
|
||||||
|
## Zielsetzung
|
||||||
|
Transformation des Portfolios von einem Standard-Layout in ein hochkarätiges "Editorial Bento Grid" Design. Fokus auf Typografie, Dynamik (Directus CMS) und Premium UX ("Liquid" Aesthetic).
|
||||||
|
|
||||||
|
## Meilensteine & Kernänderungen
|
||||||
|
|
||||||
|
### 1. UI/UX Architektur
|
||||||
|
- **Editorial Bento Grid:** Umstellung der gesamten Seite auf ein modulares Grid-System.
|
||||||
|
- **Liquid Aesthetic:** Nutzung von Glassmorphismus, extremen Abrundungen (`rounded-[3rem]`) und weichen Verläufen (`liquid-mint`, `liquid-purple`).
|
||||||
|
- **Typography-First:** Einsatz von riesigen, uppercase Headlines mit markanten Endpunkten (z.B. `Archive.`) für einen Magazin-Look.
|
||||||
|
|
||||||
|
### 2. Dynamische Features
|
||||||
|
- **ActivityFeed 2.0:**
|
||||||
|
- Integration von Echtzeit-Status (Spotify, Coding, Gaming) direkt in die Grid-Zellen.
|
||||||
|
- **Quote Carousel:** Automatischer Wechsel zwischen nerdigen Zitaten (Dijkstra, Einstein etc.) alle 10s im Idle-Mode.
|
||||||
|
- Fallback-Logik für CMS-gesteuerte Quotes (`about.quote.idle`).
|
||||||
|
- **Skeleton Loading:** Implementierung von Shimmer-Skeletons für alle dynamischen Daten (Hero, Bento, Library, Projects), um Layout-Shift zu verhindern.
|
||||||
|
|
||||||
|
### 3. Navigation & Struktur
|
||||||
|
- **Minimalist Dock:** Die Navbar wurde zu einer schwebenden, asymmetrischen "Pill" transformiert.
|
||||||
|
- **Intelligent Back Button:** Die Projektdetailseiten erkennen nun den Ursprung des Nutzers (Home vs. Archiv) und führen kontextsensitiv zurück.
|
||||||
|
- **Unified Sub-Pages:** Übertragung des Bento-Stils auf `/books`, `/projects`, `/legal-notice` und `/privacy-policy`.
|
||||||
|
|
||||||
|
### 4. Technische Stabilität
|
||||||
|
- **TypeScript Hardening:** Alle `any`-Typen in den neuen Komponenten wurden durch strikte Interfaces (`ProjectDetailData`, `ProjectListItem`) ersetzt.
|
||||||
|
- **Docker Ready:** Korrektur von Build-Errors (Klammerfehler, fehlende Imports), sodass das Image erfolgreich baut.
|
||||||
|
- **Test Suite:** Update der Jest-Tests auf das neue Design und Polyfilling von `Request`/`Response` für API-Tests.
|
||||||
|
|
||||||
|
## Design Mandate für die Zukunft
|
||||||
|
- Kontrastreiches Grün (`emerald-500`) für helle Hintergründe verwenden.
|
||||||
|
- Animationen immer mit Framer Motion (Staggered Effects, Floating).
|
||||||
|
- Keine Overlays; alle Widgets müssen Teil des Grids sein.
|
||||||
99
docs/N8N_HARDCOVER_GUIDE.md
Normal file
99
docs/N8N_HARDCOVER_GUIDE.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Automatisierung: Gelesene Bücher (Hardcover → Directus)
|
||||||
|
|
||||||
|
Diese Anleitung erklärt, wie du n8n einrichtest, damit Bücher, die du auf Hardcover als "Read" markierst, automatisch in deinem Directus CMS landen.
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
- **Quelle:** Hardcover (Status: Read)
|
||||||
|
- **Ziel:** Directus (Collection: `book_reviews`)
|
||||||
|
- **Verhalten:**
|
||||||
|
- Buch wird automatisch angelegt.
|
||||||
|
- Status wird auf `draft` gesetzt (damit du optional eine Bewertung/Review schreiben kannst).
|
||||||
|
- Wenn du keine Review schreiben willst, kannst du den Status im n8n Workflow direkt auf `published` setzen.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
1. **n8n Instanz** (self-hosted oder Cloud).
|
||||||
|
2. **Directus URL & Token** (Admin oder Token mit Schreibrechten auf `book_reviews`).
|
||||||
|
3. **Hardcover Account** (GraphQL API Zugriff).
|
||||||
|
|
||||||
|
## Schritt-für-Schritt Einrichtung
|
||||||
|
|
||||||
|
### 1. Directus Collection `book_reviews` vorbereiten
|
||||||
|
Stelle sicher, dass deine Collection in Directus folgende Felder hat (nullable = optional):
|
||||||
|
- `status` (String: `draft`, `published`, `archived`)
|
||||||
|
- `book_title` (String, required)
|
||||||
|
- `book_author` (String, required)
|
||||||
|
- `book_image` (String, URL)
|
||||||
|
- `rating` (Integer, nullable, 1-5)
|
||||||
|
- `hardcover_id` (String, unique, um Duplikate zu vermeiden)
|
||||||
|
- `finished_at` (Date, wann du es gelesen hast)
|
||||||
|
- `review` (Text/Markdown, nullable - DEINE Meinung)
|
||||||
|
|
||||||
|
### 2. n8n Workflow erstellen
|
||||||
|
Erstelle einen neuen Workflow in n8n.
|
||||||
|
|
||||||
|
#### Node 1: Trigger (Zeitgesteuert)
|
||||||
|
- **Typ:** `Schedule Trigger`
|
||||||
|
- **Intervall:** Alle 60 Minuten (oder wie oft du willst).
|
||||||
|
|
||||||
|
#### Node 2: Hardcover API Abfrage
|
||||||
|
- **Typ:** `GraphQL` (oder `HTTP Request` POST an `https://api.hardcover.app/graphql`)
|
||||||
|
- **Query:**
|
||||||
|
```graphql
|
||||||
|
query {
|
||||||
|
me {
|
||||||
|
books_read(limit: 5, order_by: {finished_at: desc}) {
|
||||||
|
finished_at
|
||||||
|
book {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
contributions {
|
||||||
|
author {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
images {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Auth:** Bearer Token (Dein Hardcover API Key).
|
||||||
|
|
||||||
|
#### Node 3: Auf neue Bücher prüfen
|
||||||
|
- **Typ:** `Function` / `Code`
|
||||||
|
- **Logik:** Vergleiche die `id` von Hardcover mit den `hardcover_id`s, die schon in Directus sind (du musst vorher eine Directus Abfrage machen, um existierende IDs zu holen).
|
||||||
|
- **Ziel:** Filtere Bücher heraus, die schon importiert wurden.
|
||||||
|
|
||||||
|
#### Node 4: Buch in Directus anlegen
|
||||||
|
- **Typ:** `Directus` (oder `HTTP Request` POST an dein Directus)
|
||||||
|
- **Resource:** `Items` -> `book_reviews` -> `Create`
|
||||||
|
- **Mapping:**
|
||||||
|
- `book_title`: `{{ $json.book.title }}`
|
||||||
|
- `book_author`: `{{ $json.book.contributions[0].author.name }}`
|
||||||
|
- `book_image`: `{{ $json.book.images[0].url }}`
|
||||||
|
- `hardcover_id`: `{{ $json.book.id }}`
|
||||||
|
- `finished_at`: `{{ $json.finished_at }}`
|
||||||
|
- `status`: `draft` (oder `published` wenn du es sofort live haben willst)
|
||||||
|
- `rating`: `null` (das füllst du dann manuell in Directus aus!)
|
||||||
|
- `review`: `null` (das schreibst du dann manuell in Directus!)
|
||||||
|
|
||||||
|
### 3. Workflow aktivieren
|
||||||
|
- Teste den Workflow einmal manuell.
|
||||||
|
- Aktiviere ihn ("Active" Switch oben rechts).
|
||||||
|
|
||||||
|
## Workflow: Bewertung schreiben (Optional)
|
||||||
|
1. Das Buch erscheint automatisch in Directus als `draft`.
|
||||||
|
2. Du bekommst (optional) eine Benachrichtigung (via n8n -> Email/Discord/Telegram).
|
||||||
|
3. Du loggst dich in Directus ein.
|
||||||
|
4. Du öffnest das Buch.
|
||||||
|
5. **Möchtest du bewerten?**
|
||||||
|
- Ja: Gib `rating` (1-5) und `review` Text ein. Setze Status auf `published`.
|
||||||
|
- Nein, nur auflisten: Lass `rating` leer. Setze Status auf `published`.
|
||||||
|
|
||||||
|
## Frontend Logik (Code Anpassung)
|
||||||
|
Der Code im Frontend (`ReadBooks.tsx`) ist bereits so gebaut, dass er:
|
||||||
|
- Bücher anzeigt, die `status: published` haben.
|
||||||
|
- Wenn `rating` vorhanden ist, werden Sterne angezeigt.
|
||||||
|
- Wenn `review` vorhanden ist, wird der Text angezeigt.
|
||||||
|
- Wenn beides fehlt, wird das Buch einfach nur als "Gelesen" aufgelistet (Cover + Titel + Autor).
|
||||||
@@ -16,6 +16,8 @@ const eslintConfig = [
|
|||||||
".next/**",
|
".next/**",
|
||||||
"out/**",
|
"out/**",
|
||||||
"build/**",
|
"build/**",
|
||||||
|
"coverage/**",
|
||||||
|
"scripts/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
202
jest.setup.ts
202
jest.setup.ts
@@ -1,155 +1,69 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import "whatwg-fetch";
|
import { Request, Response, Headers } from "node-fetch";
|
||||||
import React from "react";
|
|
||||||
import { render } from "@testing-library/react";
|
|
||||||
import { ToastProvider } from "@/components/Toast";
|
|
||||||
|
|
||||||
// Mock Next.js router
|
// Mock matchMedia
|
||||||
jest.mock("next/navigation", () => ({
|
Object.defineProperty(window, "matchMedia", {
|
||||||
useRouter() {
|
writable: true,
|
||||||
return {
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
push: jest.fn(),
|
matches: false,
|
||||||
replace: jest.fn(),
|
media: query,
|
||||||
prefetch: jest.fn(),
|
onchange: null,
|
||||||
back: jest.fn(),
|
addListener: jest.fn(),
|
||||||
pathname: "/",
|
removeListener: jest.fn(),
|
||||||
query: {},
|
addEventListener: jest.fn(),
|
||||||
asPath: "/",
|
removeEventListener: jest.fn(),
|
||||||
};
|
dispatchEvent: jest.fn(),
|
||||||
},
|
})),
|
||||||
usePathname() {
|
|
||||||
return "/";
|
|
||||||
},
|
|
||||||
useSearchParams() {
|
|
||||||
return new URLSearchParams();
|
|
||||||
},
|
|
||||||
notFound: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock next-intl (ESM) for Jest
|
|
||||||
jest.mock("next-intl", () => ({
|
|
||||||
useLocale: () => "en",
|
|
||||||
useTranslations:
|
|
||||||
(namespace?: string) =>
|
|
||||||
(key: string) => {
|
|
||||||
if (namespace === "nav") {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
home: "Home",
|
|
||||||
about: "About",
|
|
||||||
projects: "Projects",
|
|
||||||
contact: "Contact",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
if (namespace === "common") {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
backToHome: "Back to Home",
|
|
||||||
backToProjects: "Back to Projects",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
if (namespace === "home.hero") {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
"features.f1": "Next.js & Flutter",
|
|
||||||
"features.f2": "Docker Swarm & CI/CD",
|
|
||||||
"features.f3": "Self-Hosted Infrastructure",
|
|
||||||
description:
|
|
||||||
"Student and passionate self-hoster building full-stack web apps and mobile solutions. I run my own infrastructure and love exploring DevOps.",
|
|
||||||
ctaWork: "View My Work",
|
|
||||||
ctaContact: "Contact Me",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
if (namespace === "home.about") {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
title: "About Me",
|
|
||||||
p1: "Hi, I'm Dennis – a student and passionate self-hoster based in Osnabrück, Germany.",
|
|
||||||
p2: "I love building full-stack web applications with Next.js and mobile apps with Flutter. But what really excites me is DevOps: I run my own infrastructure and automate deployments with CI/CD.",
|
|
||||||
p3: "When I'm not coding or tinkering with servers, you'll find me gaming, jogging, or experimenting with automation workflows.",
|
|
||||||
funFactTitle: "Fun Fact",
|
|
||||||
funFactBody:
|
|
||||||
"Even though I automate a lot, I still use pen and paper for my calendar and notes – it helps me stay focused.",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
if (namespace === "home.contact") {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
title: "Contact Me",
|
|
||||||
subtitle:
|
|
||||||
"Interested in working together or have questions about my projects? Feel free to reach out!",
|
|
||||||
getInTouch: "Get In Touch",
|
|
||||||
getInTouchBody:
|
|
||||||
"I'm always available to discuss new opportunities, interesting projects, or simply chat about technology and innovation.",
|
|
||||||
};
|
|
||||||
return map[key] || key;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
},
|
|
||||||
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) =>
|
|
||||||
React.createElement(React.Fragment, null, children),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock next/link
|
|
||||||
jest.mock("next/link", () => {
|
|
||||||
return function Link({
|
|
||||||
children,
|
|
||||||
href,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
href: string;
|
|
||||||
}) {
|
|
||||||
return React.createElement("a", { href }, children);
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock next/image
|
// Mock IntersectionObserver
|
||||||
jest.mock("next/image", () => {
|
class MockIntersectionObserver {
|
||||||
return function Image({
|
observe = jest.fn();
|
||||||
src,
|
unobserve = jest.fn();
|
||||||
alt,
|
disconnect = jest.fn();
|
||||||
...props
|
}
|
||||||
}: React.ImgHTMLAttributes<HTMLImageElement>) {
|
|
||||||
return React.createElement("img", { src, alt, ...props });
|
Object.defineProperty(window, "IntersectionObserver", {
|
||||||
};
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: MockIntersectionObserver,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock react-responsive-masonry if it's used
|
// Polyfill Headers/Request/Response
|
||||||
jest.mock("react-responsive-masonry", () => {
|
if (!global.Headers) {
|
||||||
const MasonryComponent = function Masonry({
|
// @ts-expect-error - Polyfilling global Headers for jest environment
|
||||||
children,
|
global.Headers = Headers;
|
||||||
}: {
|
}
|
||||||
children: React.ReactNode;
|
if (!global.Request) {
|
||||||
}) {
|
// @ts-expect-error - Polyfilling global Request for jest environment
|
||||||
return React.createElement("div", { "data-testid": "masonry" }, children);
|
global.Request = Request;
|
||||||
};
|
}
|
||||||
|
if (!global.Response) {
|
||||||
const ResponsiveMasonryComponent = function ResponsiveMasonry({
|
// @ts-expect-error - Polyfilling global Response for jest environment
|
||||||
children,
|
global.Response = Response;
|
||||||
}: {
|
}
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return React.createElement(
|
|
||||||
"div",
|
|
||||||
{ "data-testid": "responsive-masonry" },
|
|
||||||
children,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Mock NextResponse
|
||||||
|
jest.mock('next/server', () => {
|
||||||
|
const actual = jest.requireActual('next/server');
|
||||||
return {
|
return {
|
||||||
__esModule: true,
|
...actual,
|
||||||
default: MasonryComponent,
|
NextResponse: {
|
||||||
ResponsiveMasonry: ResponsiveMasonryComponent,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
json: (data: Record<string, unknown>, init?: any) => {
|
||||||
|
// Use global Response from whatwg-fetch
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const res = new (global as any).Response(JSON.stringify(data), init);
|
||||||
|
res.headers.set('Content-Type', 'application/json');
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
next: () => ({ headers: new Headers() }),
|
||||||
|
redirect: (_url: string) => ({ headers: new Headers(), status: 302 }),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom render function with ToastProvider
|
// Env vars for tests
|
||||||
const customRender = (ui: React.ReactElement, options = {}) =>
|
process.env.DIRECTUS_URL = "http://localhost:8055";
|
||||||
render(ui, {
|
process.env.DIRECTUS_TOKEN = "test-token";
|
||||||
wrapper: ({ children }) =>
|
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000";
|
||||||
React.createElement(ToastProvider, null, children),
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-export everything
|
|
||||||
export * from "@testing-library/react";
|
|
||||||
export { customRender as render };
|
|
||||||
|
|||||||
521
lib/directus.ts
521
lib/directus.ts
@@ -24,7 +24,11 @@ function toDirectusLocale(locale: string): string {
|
|||||||
|
|
||||||
interface FetchOptions {
|
interface FetchOptions {
|
||||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
body?: any;
|
body?: {
|
||||||
|
query?: string;
|
||||||
|
variables?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function directusRequest<T>(
|
async function directusRequest<T>(
|
||||||
@@ -75,9 +79,9 @@ async function directusRequest<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data?.data || null;
|
return data?.data || null;
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
// Timeout oder Network Error - stille fallback
|
// Timeout oder Network Error - stille fallback
|
||||||
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
if (error && typeof error === 'object' && 'name' in error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('Directus timeout');
|
console.error('Directus timeout');
|
||||||
}
|
}
|
||||||
@@ -85,66 +89,100 @@ async function directusRequest<T>(
|
|||||||
}
|
}
|
||||||
// Andere Errors nur in dev loggen
|
// Andere Errors nur in dev loggen
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('Directus request failed:', error?.message);
|
const message = error && typeof error === 'object' && 'message' in error ? String(error.message) : 'Unknown error';
|
||||||
|
console.error('Directus request failed:', message);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMessage(key: string, locale: string): Promise<string | null> {
|
export async function getMessages(locale: string): Promise<Record<string, string>> {
|
||||||
// Note: messages collection doesn't exist in Directus yet
|
|
||||||
// The app uses JSON files as fallback via i18n-loader
|
|
||||||
// Return null to skip Directus and use JSON fallback directly
|
|
||||||
return null;
|
|
||||||
|
|
||||||
/* Commented out until messages collection is created in Directus
|
|
||||||
const directusLocale = toDirectusLocale(locale);
|
const directusLocale = toDirectusLocale(locale);
|
||||||
|
|
||||||
// GraphQL Query für Directus Native Translations
|
|
||||||
// Hole alle translations, filter client-side da GraphQL filter komplex ist
|
|
||||||
const query = `
|
const query = `
|
||||||
query {
|
query {
|
||||||
messages(filter: {key: {_eq: "${key}"}}, limit: 1) {
|
messages {
|
||||||
key
|
key
|
||||||
translations {
|
translations {
|
||||||
value
|
value
|
||||||
languages_code {
|
languages_code { code }
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await directusRequest(
|
const result = await directusRequest('', { body: { query } });
|
||||||
'',
|
interface MessageData {
|
||||||
{ body: { query } }
|
messages: Array<{
|
||||||
);
|
key: string;
|
||||||
|
translations?: Array<{
|
||||||
const messages = (result as any)?.messages;
|
languages_code?: { code: string };
|
||||||
if (!messages || messages.length === 0) {
|
value?: string;
|
||||||
return null;
|
}>;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
const messages = (result as MessageData | null)?.messages || [];
|
||||||
|
const dictionary: Record<string, string> = {};
|
||||||
|
|
||||||
|
messages.forEach((m) => {
|
||||||
|
const trans = m.translations?.find((t) => t.languages_code?.code === directusLocale);
|
||||||
|
if (trans?.value) dictionary[m.key] = trans.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return dictionary;
|
||||||
|
} catch (_error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single message by key from Directus
|
||||||
|
*/
|
||||||
|
export async function getMessage(key: string, locale: string): Promise<string | null> {
|
||||||
|
const directusLocale = toDirectusLocale(locale);
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
messages(filter: {key: {_eq: "${key}"}}, limit: 1) {
|
||||||
|
key
|
||||||
|
translations {
|
||||||
|
value
|
||||||
|
languages_code { code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest('', { body: { query } });
|
||||||
|
interface SingleMessageData {
|
||||||
|
messages: Array<{
|
||||||
|
translations?: Array<{
|
||||||
|
languages_code?: { code: string };
|
||||||
|
value?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const messages = (result as SingleMessageData | null)?.messages;
|
||||||
|
if (!messages || messages.length === 0) return null;
|
||||||
|
|
||||||
// Hole die Translation für die gewünschte Locale (client-side filter)
|
|
||||||
const translations = messages[0]?.translations || [];
|
const translations = messages[0]?.translations || [];
|
||||||
const translation = translations.find((t: any) =>
|
const translation = translations.find((t) => t.languages_code?.code === directusLocale);
|
||||||
t.languages_code?.code === directusLocale
|
|
||||||
);
|
|
||||||
|
|
||||||
return translation?.value || null;
|
return translation?.value || null;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error(`Failed to fetch message ${key} (${locale}):`, error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
*/
|
}
|
||||||
|
|
||||||
|
export interface ContentPage {
|
||||||
|
slug: string;
|
||||||
|
content?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContentPage(
|
export async function getContentPage(
|
||||||
slug: string,
|
slug: string,
|
||||||
locale: string
|
locale: string
|
||||||
): Promise<any | null> {
|
): Promise<ContentPage | null> {
|
||||||
const directusLocale = toDirectusLocale(locale);
|
const directusLocale = toDirectusLocale(locale);
|
||||||
const query = `
|
const query = `
|
||||||
query {
|
query {
|
||||||
@@ -172,7 +210,10 @@ export async function getContentPage(
|
|||||||
{ body: { query } }
|
{ body: { query } }
|
||||||
);
|
);
|
||||||
|
|
||||||
const pages = (result as any)?.content_pages || [];
|
interface ContentPagesResult {
|
||||||
|
content_pages: ContentPage[];
|
||||||
|
}
|
||||||
|
const pages = (result as ContentPagesResult | null)?.content_pages || [];
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
// Try without locale filter
|
// Try without locale filter
|
||||||
const fallbackQuery = `
|
const fallbackQuery = `
|
||||||
@@ -190,7 +231,7 @@ export async function getContentPage(
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const fallbackResult = await directusRequest('', { body: { query: fallbackQuery } });
|
const fallbackResult = await directusRequest('', { body: { query: fallbackQuery } });
|
||||||
const fallbackPages = (fallbackResult as any)?.content_pages || [];
|
const fallbackPages = (fallbackResult as ContentPagesResult | null)?.content_pages || [];
|
||||||
return fallbackPages[0] || null;
|
return fallbackPages[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +279,6 @@ const fallbackTechStackData: Record<string, Array<{ key: string; items: string[]
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryIconMap: Record<string, string> = {
|
|
||||||
frontend: 'Globe',
|
|
||||||
backend: 'Server',
|
|
||||||
tools: 'Wrench',
|
|
||||||
security: 'Shield'
|
|
||||||
};
|
|
||||||
|
|
||||||
const categoryNames: Record<string, Record<string, string>> = {
|
const categoryNames: Record<string, Record<string, string>> = {
|
||||||
'en-US': {
|
'en-US': {
|
||||||
frontend: 'Frontend & Mobile',
|
frontend: 'Frontend & Mobile',
|
||||||
@@ -291,7 +325,19 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
|||||||
{ body: { query: categoriesQuery } }
|
{ body: { query: categoriesQuery } }
|
||||||
);
|
);
|
||||||
|
|
||||||
const categories = (categoriesResult as any)?.tech_stack_categories;
|
interface TechStackCategoriesResult {
|
||||||
|
tech_stack_categories: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
sort: number;
|
||||||
|
translations?: Array<{
|
||||||
|
languages_code?: { code: string };
|
||||||
|
name?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const categories = (categoriesResult as TechStackCategoriesResult | null)?.tech_stack_categories;
|
||||||
|
|
||||||
if (!categories || categories.length === 0) {
|
if (!categories || categories.length === 0) {
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
@@ -315,15 +361,25 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
|||||||
);
|
);
|
||||||
|
|
||||||
const itemsData = await itemsResponse.json();
|
const itemsData = await itemsResponse.json();
|
||||||
const allItems = itemsData?.data || [];
|
interface ItemsResponseData {
|
||||||
|
data: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string | number;
|
||||||
|
url?: string;
|
||||||
|
icon_url?: string;
|
||||||
|
sort: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const allItems = (itemsData as ItemsResponseData | null)?.data || [];
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('[getTechStack] Fetched items:', allItems.length);
|
console.log('[getTechStack] Fetched items:', allItems.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group items by category
|
// Group items by category
|
||||||
const categoriesWithItems = categories.map((cat: any) => {
|
const categoriesWithItems = categories.map((cat) => {
|
||||||
const categoryItems = allItems.filter((item: any) =>
|
const categoryItems = allItems.filter((item) =>
|
||||||
item.category === cat.id || item.category === parseInt(cat.id)
|
item.category === cat.id || item.category === parseInt(cat.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -336,6 +392,7 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
|||||||
itemsToUse = categoryFallback.items.map((name, idx) => ({
|
itemsToUse = categoryFallback.items.map((name, idx) => ({
|
||||||
id: `fallback-${cat.key}-${idx}`,
|
id: `fallback-${cat.key}-${idx}`,
|
||||||
name: name,
|
name: name,
|
||||||
|
category: cat.id,
|
||||||
url: undefined,
|
url: undefined,
|
||||||
icon_url: undefined,
|
icon_url: undefined,
|
||||||
sort: idx + 1
|
sort: idx + 1
|
||||||
@@ -349,7 +406,7 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
|||||||
icon: cat.icon,
|
icon: cat.icon,
|
||||||
sort: cat.sort,
|
sort: cat.sort,
|
||||||
name: cat.translations?.[0]?.name || categoryNames[directusLocale]?.[cat.key] || cat.key,
|
name: cat.translations?.[0]?.name || categoryNames[directusLocale]?.[cat.key] || cat.key,
|
||||||
items: itemsToUse.map((item: any) => ({
|
items: itemsToUse.map((item: TechStackItem) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
@@ -360,8 +417,8 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
|||||||
});
|
});
|
||||||
|
|
||||||
return categoriesWithItems;
|
return categoriesWithItems;
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error(`Failed to fetch tech stack (${locale}):`, error);
|
console.error(`Failed to fetch tech stack (${locale}):`, _error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,12 +461,23 @@ export async function getHobbies(locale: string): Promise<Hobby[] | null> {
|
|||||||
{ body: { query } }
|
{ body: { query } }
|
||||||
);
|
);
|
||||||
|
|
||||||
const hobbies = (result as any)?.hobbies;
|
interface HobbiesResult {
|
||||||
|
hobbies: Array<{
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
icon: string;
|
||||||
|
translations?: Array<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const hobbies = (result as HobbiesResult | null)?.hobbies;
|
||||||
if (!hobbies || hobbies.length === 0) {
|
if (!hobbies || hobbies.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return hobbies.map((hobby: any) => ({
|
return hobbies.map((hobby) => ({
|
||||||
id: hobby.id,
|
id: hobby.id,
|
||||||
key: hobby.key,
|
key: hobby.key,
|
||||||
icon: hobby.icon,
|
icon: hobby.icon,
|
||||||
@@ -422,9 +490,99 @@ export async function getHobbies(locale: string): Promise<Hobby[] | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Book Review Types
|
||||||
|
export interface BookReview {
|
||||||
|
id: string;
|
||||||
|
hardcover_id?: string;
|
||||||
|
book_title: string;
|
||||||
|
book_author: string;
|
||||||
|
book_image?: string;
|
||||||
|
rating: number; // 1-5
|
||||||
|
review?: string; // Translated review text
|
||||||
|
finished_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Book Reviews from Directus with translations
|
||||||
|
*/
|
||||||
|
export async function getBookReviews(locale: string): Promise<BookReview[] | null> {
|
||||||
|
const directusLocale = toDirectusLocale(locale);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
book_reviews(
|
||||||
|
filter: { status: { _eq: "published" } }
|
||||||
|
sort: ["-finished_at"]
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
hardcover_id
|
||||||
|
book_title
|
||||||
|
book_author
|
||||||
|
book_image
|
||||||
|
rating
|
||||||
|
finished_at
|
||||||
|
translations {
|
||||||
|
review
|
||||||
|
languages_code {
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface BookReviewsResult {
|
||||||
|
book_reviews: Array<{
|
||||||
|
id: string;
|
||||||
|
hardcover_id?: string;
|
||||||
|
book_title: string;
|
||||||
|
book_author: string;
|
||||||
|
book_image?: string;
|
||||||
|
rating: number | string;
|
||||||
|
finished_at?: string;
|
||||||
|
translations?: Array<{
|
||||||
|
review?: string;
|
||||||
|
languages_code?: { code: string };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const reviews = (result as BookReviewsResult | null)?.book_reviews;
|
||||||
|
if (!reviews || reviews.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reviews.map((item) => {
|
||||||
|
// Filter die passende Übersetzung im Code
|
||||||
|
const translation = item.translations?.find(
|
||||||
|
(t) => t.languages_code?.code === directusLocale
|
||||||
|
) || item.translations?.[0]; // Fallback auf die erste Übersetzung falls locale nicht passt
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
hardcover_id: item.hardcover_id || undefined,
|
||||||
|
book_title: item.book_title,
|
||||||
|
book_author: item.book_author,
|
||||||
|
book_image: item.book_image || undefined,
|
||||||
|
rating: typeof item.rating === 'number' ? item.rating : parseInt(item.rating) || 0,
|
||||||
|
review: translation?.review || undefined,
|
||||||
|
finished_at: item.finished_at || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch book reviews (${locale}):`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Projects Types
|
// Projects Types
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string | number; // Allow both string (from Directus) and number (from Prisma)
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -438,6 +596,8 @@ export interface Project {
|
|||||||
future_improvements?: string;
|
future_improvements?: string;
|
||||||
github_url?: string;
|
github_url?: string;
|
||||||
live_url?: string;
|
live_url?: string;
|
||||||
|
button_live_label?: string;
|
||||||
|
button_github_label?: string;
|
||||||
image_url?: string;
|
image_url?: string;
|
||||||
demo_video_url?: string;
|
demo_video_url?: string;
|
||||||
performance_metrics?: string;
|
performance_metrics?: string;
|
||||||
@@ -527,6 +687,8 @@ export async function getProjects(
|
|||||||
content
|
content
|
||||||
meta_description
|
meta_description
|
||||||
keywords
|
keywords
|
||||||
|
button_live_label
|
||||||
|
button_github_label
|
||||||
languages_code { code }
|
languages_code { code }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -539,19 +701,52 @@ export async function getProjects(
|
|||||||
{ body: { query } }
|
{ body: { query } }
|
||||||
);
|
);
|
||||||
|
|
||||||
const projects = (result as any)?.projects;
|
interface ProjectsResult {
|
||||||
|
projects: Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
tags?: string[] | string;
|
||||||
|
technologies?: string[] | string;
|
||||||
|
challenges?: string;
|
||||||
|
lessons_learned?: string;
|
||||||
|
future_improvements?: string;
|
||||||
|
github?: string;
|
||||||
|
live?: string;
|
||||||
|
image_url?: string;
|
||||||
|
demo_video?: string;
|
||||||
|
performance_metrics?: string;
|
||||||
|
screenshots?: string[] | string;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
featured?: boolean | number;
|
||||||
|
status?: string;
|
||||||
|
translations?: Array<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
content?: string;
|
||||||
|
meta_description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
button_live_label?: string;
|
||||||
|
button_github_label?: string;
|
||||||
|
languages_code?: { code: string };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const projects = (result as ProjectsResult | null)?.projects;
|
||||||
if (!projects || projects.length === 0) {
|
if (!projects || projects.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return projects.map((proj: any) => {
|
return projects.map((proj) => {
|
||||||
const trans =
|
const trans =
|
||||||
proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) ||
|
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
||||||
proj.translations?.[0] ||
|
proj.translations?.[0] ||
|
||||||
{};
|
{};
|
||||||
|
|
||||||
// Parse JSON string fields if needed
|
// Parse JSON string fields if needed
|
||||||
const parseTags = (tags: any) => {
|
const parseTags = (tags: string[] | string | undefined): string[] => {
|
||||||
if (!tags) return [];
|
if (!tags) return [];
|
||||||
if (Array.isArray(tags)) return tags;
|
if (Array.isArray(tags)) return tags;
|
||||||
if (typeof tags === 'string') {
|
if (typeof tags === 'string') {
|
||||||
@@ -563,7 +758,7 @@ export async function getProjects(
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: proj.id,
|
id: proj.id,
|
||||||
slug: proj.slug,
|
slug: proj.slug,
|
||||||
@@ -579,6 +774,8 @@ export async function getProjects(
|
|||||||
future_improvements: proj.future_improvements,
|
future_improvements: proj.future_improvements,
|
||||||
github_url: proj.github,
|
github_url: proj.github,
|
||||||
live_url: proj.live,
|
live_url: proj.live,
|
||||||
|
button_live_label: trans.button_live_label,
|
||||||
|
button_github_label: trans.button_github_label,
|
||||||
image_url: proj.image_url,
|
image_url: proj.image_url,
|
||||||
demo_video_url: proj.demo_video,
|
demo_video_url: proj.demo_video,
|
||||||
performance_metrics: proj.performance_metrics,
|
performance_metrics: proj.performance_metrics,
|
||||||
@@ -589,8 +786,210 @@ export async function getProjects(
|
|||||||
updated_at: proj.date_updated
|
updated_at: proj.date_updated
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error(`Failed to fetch projects (${locale}):`, error);
|
console.error(`Failed to fetch projects (${locale}):`, _error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single project by slug from Directus
|
||||||
|
*/
|
||||||
|
export async function getProjectBySlug(
|
||||||
|
slug: string,
|
||||||
|
locale: string
|
||||||
|
): Promise<Project | null> {
|
||||||
|
const directusLocale = toDirectusLocale(locale);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
projects(
|
||||||
|
filter: {
|
||||||
|
_and: [
|
||||||
|
{ slug: { _eq: "${slug}" } },
|
||||||
|
{ status: { _eq: "published" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
limit: 1
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
category
|
||||||
|
difficulty
|
||||||
|
tags
|
||||||
|
technologies
|
||||||
|
challenges
|
||||||
|
lessons_learned
|
||||||
|
future_improvements
|
||||||
|
github
|
||||||
|
live
|
||||||
|
image_url
|
||||||
|
demo_video
|
||||||
|
date_created
|
||||||
|
date_updated
|
||||||
|
featured
|
||||||
|
status
|
||||||
|
translations {
|
||||||
|
title
|
||||||
|
description
|
||||||
|
content
|
||||||
|
meta_description
|
||||||
|
keywords
|
||||||
|
button_live_label
|
||||||
|
button_github_label
|
||||||
|
languages_code { code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ProjectResult {
|
||||||
|
projects: Array<{
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
tags?: string[] | string;
|
||||||
|
technologies?: string[] | string;
|
||||||
|
challenges?: string;
|
||||||
|
lessons_learned?: string;
|
||||||
|
future_improvements?: string;
|
||||||
|
github?: string;
|
||||||
|
live?: string;
|
||||||
|
image_url?: string;
|
||||||
|
demo_video?: string;
|
||||||
|
screenshots?: string[] | string;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
featured?: boolean | number;
|
||||||
|
status?: string;
|
||||||
|
translations?: Array<{
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
content?: string;
|
||||||
|
meta_description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
button_live_label?: string;
|
||||||
|
button_github_label?: string;
|
||||||
|
languages_code?: { code: string };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
const projects = (result as ProjectResult | null)?.projects;
|
||||||
|
if (!projects || projects.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proj = projects[0];
|
||||||
|
const trans =
|
||||||
|
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
||||||
|
proj.translations?.[0] ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
// Parse JSON string fields if needed
|
||||||
|
const parseTags = (tags: string[] | string | undefined): string[] => {
|
||||||
|
if (!tags) return [];
|
||||||
|
if (Array.isArray(tags)) return tags;
|
||||||
|
if (typeof tags === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(tags);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: proj.id,
|
||||||
|
slug: proj.slug,
|
||||||
|
title: trans.title || proj.slug,
|
||||||
|
description: trans.description || '',
|
||||||
|
content: trans.content,
|
||||||
|
category: proj.category,
|
||||||
|
difficulty: proj.difficulty,
|
||||||
|
tags: parseTags(proj.tags),
|
||||||
|
technologies: parseTags(proj.technologies),
|
||||||
|
challenges: proj.challenges,
|
||||||
|
lessons_learned: proj.lessons_learned,
|
||||||
|
future_improvements: proj.future_improvements,
|
||||||
|
github_url: proj.github,
|
||||||
|
live_url: proj.live,
|
||||||
|
button_live_label: trans.button_live_label,
|
||||||
|
button_github_label: trans.button_github_label,
|
||||||
|
image_url: proj.image_url,
|
||||||
|
demo_video_url: proj.demo_video,
|
||||||
|
screenshots: parseTags(proj.screenshots),
|
||||||
|
featured: proj.featured === 1 || proj.featured === true,
|
||||||
|
published: proj.status === 'published',
|
||||||
|
created_at: proj.date_created,
|
||||||
|
updated_at: proj.date_updated
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
console.error(`Failed to fetch project by slug ${slug} (${locale}):`, _error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snippets Types
|
||||||
|
export interface Snippet {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Snippets from Directus
|
||||||
|
*/
|
||||||
|
export async function getSnippets(limit = 10, featured?: boolean): Promise<Snippet[] | null> {
|
||||||
|
const filters = ['status: { _eq: "published" }'];
|
||||||
|
if (featured !== undefined) {
|
||||||
|
filters.push(`featured: { _eq: ${featured} }`);
|
||||||
|
}
|
||||||
|
const filterString = `filter: { _and: [{ ${filters.join(' }, { ')} }] }`;
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
snippets(
|
||||||
|
${filterString}
|
||||||
|
limit: ${limit}
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
category
|
||||||
|
code
|
||||||
|
description
|
||||||
|
language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SnippetsResult {
|
||||||
|
snippets: Snippet[];
|
||||||
|
}
|
||||||
|
const snippets = (result as SnippetsResult | null)?.snippets;
|
||||||
|
if (!snippets || snippets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippets;
|
||||||
|
} catch (_error) {
|
||||||
|
console.error('Failed to fetch snippets:', _error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,20 @@
|
|||||||
* - Caches results (5 min TTL)
|
* - Caches results (5 min TTL)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getMessage, getContentPage } from './directus';
|
import { getMessage, getContentPage, ContentPage } from './directus';
|
||||||
import enMessages from '@/messages/en.json';
|
import enMessages from '@/messages/en.json';
|
||||||
import deMessages from '@/messages/de.json';
|
import deMessages from '@/messages/de.json';
|
||||||
|
|
||||||
const jsonFallback = { en: enMessages, de: deMessages };
|
const jsonFallback = { en: enMessages, de: deMessages };
|
||||||
|
|
||||||
// Simple in-memory cache
|
// Simple in-memory cache
|
||||||
const cache = new Map<string, { value: any; expires: number }>();
|
const cache = new Map<string, { value: unknown; expires: number }>();
|
||||||
|
|
||||||
function setCached(key: string, value: any, ttlSeconds = 300) {
|
function setCached(key: string, value: unknown, ttlSeconds = 300) {
|
||||||
cache.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
cache.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCached(key: string): any | null {
|
function getCached(key: string): unknown | null {
|
||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (Date.now() > hit.expires) {
|
if (Date.now() > hit.expires) {
|
||||||
@@ -38,7 +38,7 @@ export async function getLocalizedMessage(
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const cacheKey = `msg:${key}:${locale}`;
|
const cacheKey = `msg:${key}:${locale}`;
|
||||||
const cached = getCached(cacheKey);
|
const cached = getCached(cacheKey);
|
||||||
if (cached !== null) return cached;
|
if (cached !== null) return cached as string;
|
||||||
|
|
||||||
// Try Directus with requested locale
|
// Try Directus with requested locale
|
||||||
const dbValue = await getMessage(key, locale);
|
const dbValue = await getMessage(key, locale);
|
||||||
@@ -84,11 +84,11 @@ export async function getLocalizedMessage(
|
|||||||
export async function getLocalizedContent(
|
export async function getLocalizedContent(
|
||||||
slug: string,
|
slug: string,
|
||||||
locale: string
|
locale: string
|
||||||
): Promise<any | null> {
|
): Promise<ContentPage | null> {
|
||||||
const cacheKey = `page:${slug}:${locale}`;
|
const cacheKey = `page:${slug}:${locale}`;
|
||||||
const cached = getCached(cacheKey);
|
const cached = getCached(cacheKey);
|
||||||
if (cached !== null) return cached;
|
if (cached !== null) return cached as ContentPage;
|
||||||
if (cached === null && cache.has(cacheKey)) return null; // Already checked, not found
|
if (cache.has(cacheKey)) return null; // Already checked, not found
|
||||||
|
|
||||||
// Try Directus with requested locale
|
// Try Directus with requested locale
|
||||||
const dbPage = await getContentPage(slug, locale);
|
const dbPage = await getContentPage(slug, locale);
|
||||||
@@ -115,14 +115,18 @@ export async function getLocalizedContent(
|
|||||||
* Helper: Get nested value from object
|
* Helper: Get nested value from object
|
||||||
* Example: "nav.home" → obj.nav.home
|
* Example: "nav.home" → obj.nav.home
|
||||||
*/
|
*/
|
||||||
function getNestedValue(obj: any, path: string): any {
|
function getNestedValue(obj: Record<string, unknown>, path: string): string | null {
|
||||||
const keys = path.split('.');
|
const keys = path.split('.');
|
||||||
let value = obj;
|
let value: unknown = obj;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
value = value?.[key];
|
if (value && typeof value === 'object' && key in value) {
|
||||||
|
value = (value as Record<string, unknown>)[key];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (value === undefined) return null;
|
if (value === undefined) return null;
|
||||||
}
|
}
|
||||||
return value;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
133
lib/richtext.ts
133
lib/richtext.ts
@@ -10,62 +10,83 @@ import Highlight from "@tiptap/extension-highlight";
|
|||||||
import { FontFamily } from "@/lib/tiptap/fontFamily";
|
import { FontFamily } from "@/lib/tiptap/fontFamily";
|
||||||
|
|
||||||
export function richTextToSafeHtml(doc: JSONContent): string {
|
export function richTextToSafeHtml(doc: JSONContent): string {
|
||||||
const raw = generateHTML(doc, [
|
if (!doc || typeof doc !== "object" || Object.keys(doc).length === 0) {
|
||||||
StarterKit,
|
return "";
|
||||||
Underline,
|
}
|
||||||
Link.configure({
|
|
||||||
openOnClick: false,
|
|
||||||
autolink: false,
|
|
||||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
|
||||||
}),
|
|
||||||
TextStyle,
|
|
||||||
FontFamily,
|
|
||||||
Color,
|
|
||||||
Highlight,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return sanitizeHtml(raw, {
|
// Ensure type is present to satisfy Tiptap requirement
|
||||||
allowedTags: [
|
const typedDoc = { ...doc };
|
||||||
"p",
|
if (!typedDoc.type) {
|
||||||
"br",
|
typedDoc.type = "doc";
|
||||||
"h1",
|
}
|
||||||
"h2",
|
|
||||||
"h3",
|
// Ensure content is an array
|
||||||
"blockquote",
|
if (!typedDoc.content) {
|
||||||
"strong",
|
typedDoc.content = [];
|
||||||
"em",
|
}
|
||||||
"u",
|
|
||||||
"a",
|
try {
|
||||||
"ul",
|
const raw = generateHTML(typedDoc, [
|
||||||
"ol",
|
StarterKit,
|
||||||
"li",
|
Underline,
|
||||||
"code",
|
Link.configure({
|
||||||
"pre",
|
openOnClick: false,
|
||||||
"span"
|
autolink: false,
|
||||||
],
|
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||||
allowedAttributes: {
|
}),
|
||||||
a: ["href", "rel", "target"],
|
TextStyle,
|
||||||
span: ["style"],
|
FontFamily,
|
||||||
code: ["class"],
|
Color,
|
||||||
pre: ["class"],
|
Highlight,
|
||||||
p: ["class"],
|
]);
|
||||||
h1: ["class"],
|
|
||||||
h2: ["class"],
|
return sanitizeHtml(raw, {
|
||||||
h3: ["class"],
|
allowedTags: [
|
||||||
blockquote: ["class"],
|
"p",
|
||||||
ul: ["class"],
|
"br",
|
||||||
ol: ["class"],
|
"h1",
|
||||||
li: ["class"]
|
"h2",
|
||||||
},
|
"h3",
|
||||||
allowedSchemes: ["http", "https", "mailto"],
|
"blockquote",
|
||||||
allowProtocolRelative: false,
|
"strong",
|
||||||
allowedStyles: {
|
"em",
|
||||||
span: {
|
"u",
|
||||||
color: [/^#[0-9a-fA-F]{3,8}$/],
|
"a",
|
||||||
"background-color": [/^#[0-9a-fA-F]{3,8}$/],
|
"ul",
|
||||||
"font-family": [/^(Inter|ui-sans-serif|ui-serif|ui-monospace)$/],
|
"ol",
|
||||||
|
"li",
|
||||||
|
"code",
|
||||||
|
"pre",
|
||||||
|
"span"
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ["href", "rel", "target"],
|
||||||
|
span: ["style"],
|
||||||
|
code: ["class"],
|
||||||
|
pre: ["class"],
|
||||||
|
p: ["class"],
|
||||||
|
h1: ["class"],
|
||||||
|
h2: ["class"],
|
||||||
|
h3: ["class"],
|
||||||
|
blockquote: ["class"],
|
||||||
|
ul: ["class"],
|
||||||
|
ol: ["class"],
|
||||||
|
li: ["class"]
|
||||||
},
|
},
|
||||||
},
|
allowedSchemes: ["http", "https", "mailto"],
|
||||||
});
|
allowProtocolRelative: false,
|
||||||
|
allowedStyles: {
|
||||||
|
span: {
|
||||||
|
color: [/^#[0-9a-fA-F]{3,8}$/],
|
||||||
|
"background-color": [/^#[0-9a-fA-F]{3,8}$/],
|
||||||
|
"font-family": [/^(Inter|ui-sans-serif|ui-serif|ui-monospace)$/],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.error("Error generating HTML from rich text:", error);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
lib/utils.ts
10
lib/utils.ts
@@ -1,7 +1,17 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility functions for the application
|
* Utility functions for the application
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine tailwind classes safely
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounce helper to prevent duplicate function calls
|
* Debounce helper to prevent duplicate function calls
|
||||||
* @param func - The function to debounce
|
* @param func - The function to debounce
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"contact": "Kontakt"
|
"contact": "Kontakt"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"back": "Zurück",
|
||||||
"backToHome": "Zurück zur Startseite",
|
"backToHome": "Zurück zur Startseite",
|
||||||
"backToProjects": "Zurück zu den Projekten",
|
"backToProjects": "Zurück zu den Projekten",
|
||||||
"viewAllProjects": "Alle Projekte ansehen",
|
"viewAllProjects": "Alle Projekte ansehen",
|
||||||
@@ -30,17 +31,17 @@
|
|||||||
"f2": "Docker Swarm & CI/CD",
|
"f2": "Docker Swarm & CI/CD",
|
||||||
"f3": "Self-Hosted Infrastruktur"
|
"f3": "Self-Hosted Infrastruktur"
|
||||||
},
|
},
|
||||||
"description": "Student und leidenschaftlicher Self-Hoster: Ich baue Full-Stack Web-Apps und Mobile-Lösungen, betreibe meine eigene Infrastruktur und liebe DevOps.",
|
"description": "Ich bin Dennis – Student aus Osnabrück und leidenschaftlicher Self-Hoster. Ich entwickle Full-Stack Apps und sorge am liebsten selbst dafür, dass sie auf meiner eigenen Infrastruktur perfekt laufen.",
|
||||||
"ctaWork": "Meine Projekte",
|
"ctaWork": "Meine Projekte",
|
||||||
"ctaContact": "Kontakt"
|
"ctaContact": "Kontakt"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "Über mich",
|
"title": "Hinter den Kulissen",
|
||||||
"p1": "Hi, ich bin Dennis – Student und leidenschaftlicher Self-Hoster aus Osnabrück.",
|
"p1": "Schon seit ich meinen ersten eigenen Server aufgesetzt habe, lässt mich das Thema Infrastruktur nicht mehr los. Als Student in Osnabrück verbringe ich meine Zeit am liebsten damit, moderne Web-Apps mit Next.js zu bauen oder mobile Lösungen mit Flutter zu entwickeln.",
|
||||||
"p2": "Ich entwickle Full-Stack Web-Apps mit Next.js und Mobile-Apps mit Flutter. Besonders spannend finde ich DevOps: eigene Infrastruktur, Automatisierung und CI/CD Deployments.",
|
"p2": "Aber für mich hört es nicht beim Code auf: Ich liebe es, meine eigenen Docker-Cluster zu managen, CI/CD-Pipelines zu optimieren und sicherzustellen, dass alles stabil und sicher läuft. DevOps ist für mich kein Job-Titel, sondern eine Lebenseinstellung.",
|
||||||
"p3": "Wenn ich nicht code oder an Servern schraube, findest du mich beim Gaming, Joggen oder beim Experimentieren mit Automationen.",
|
"p3": "Wenn die Server einmal ohne mich klarkommen, findet man mich beim Laufen durch Osnabrück, beim Gaming oder beim Experimentieren mit neuen Automationen in n8n.",
|
||||||
"funFactTitle": "Fun Fact",
|
"funFactTitle": "Hardcore analog",
|
||||||
"funFactBody": "Auch wenn ich viel automatisiere, nutze ich für Kalender & Notizen noch Stift und Papier – das hilft mir beim Fokus.",
|
"funFactBody": "Trotz Cloud und Automatisierung: Meine wichtigsten Pläne entstehen immer noch mit Füller auf Papier. Das ist mein Anker im digitalen Chaos.",
|
||||||
"techStackTitle": "Mein Tech Stack",
|
"techStackTitle": "Mein Tech Stack",
|
||||||
"hobbiesTitle": "Wenn ich nicht code",
|
"hobbiesTitle": "Wenn ich nicht code",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
@@ -63,6 +64,19 @@
|
|||||||
"currentlyReading": {
|
"currentlyReading": {
|
||||||
"title": "Aktuell am Lesen",
|
"title": "Aktuell am Lesen",
|
||||||
"progress": "Fortschritt"
|
"progress": "Fortschritt"
|
||||||
|
},
|
||||||
|
"readBooks": {
|
||||||
|
"title": "Gelesene Bücher",
|
||||||
|
"finishedAt": "Beendet am",
|
||||||
|
"showMore": "{count} weitere anzeigen",
|
||||||
|
"showLess": "Weniger anzeigen"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"idleStatus": "System im Leerlauf / Geist aktiv",
|
||||||
|
"codingNow": "Gerade am Coden",
|
||||||
|
"gaming": "Am Zocken",
|
||||||
|
"listening": "Hört gerade",
|
||||||
|
"inGame": "Im Spiel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"contact": "Contact"
|
"contact": "Contact"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
"back": "Back",
|
||||||
"backToHome": "Back to Home",
|
"backToHome": "Back to Home",
|
||||||
"backToProjects": "Back to Projects",
|
"backToProjects": "Back to Projects",
|
||||||
"viewAllProjects": "View All Projects",
|
"viewAllProjects": "View All Projects",
|
||||||
@@ -31,17 +32,17 @@
|
|||||||
"f2": "Docker Swarm & CI/CD",
|
"f2": "Docker Swarm & CI/CD",
|
||||||
"f3": "Self-Hosted Infrastructure"
|
"f3": "Self-Hosted Infrastructure"
|
||||||
},
|
},
|
||||||
"description": "Student and passionate self-hoster building full-stack web apps and mobile solutions. I run my own infrastructure and love exploring DevOps.",
|
"description": "I'm Dennis – a student from Germany and a passionate self-hoster. I build full-stack applications and love the challenge of managing the infrastructure they run on.",
|
||||||
"ctaWork": "View My Work",
|
"ctaWork": "View Projects",
|
||||||
"ctaContact": "Contact Me"
|
"ctaContact": "Get in touch"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About Me",
|
"title": "Behind the Code",
|
||||||
"p1": "Hi, I'm Dennis – a student and passionate self-hoster based in Osnabrück, Germany.",
|
"p1": "Ever since I set up my first home server, I've been hooked on infrastructure. Currently studying in Osnabrück, I split my time between developing modern web apps with Next.js and building mobile experiences with Flutter.",
|
||||||
"p2": "I love building full-stack web applications with Next.js and mobile apps with Flutter. But what really excites me is DevOps: I run my own infrastructure and automate deployments with CI/CD.",
|
"p2": "For me, it doesn't stop at the code. I genuinely enjoy managing my own Docker clusters, optimizing CI/CD pipelines, and making sure everything is stable and secure. DevOps isn't just a part of my job – it's how I think about building things.",
|
||||||
"p3": "When I'm not coding or tinkering with servers, you'll find me gaming, jogging, or experimenting with automation workflows.",
|
"p3": "When the servers are running smoothly, you'll find me jogging through the city, gaming, or tinkering with new automation workflows in n8n.",
|
||||||
"funFactTitle": "Fun Fact",
|
"funFactTitle": "Hardcore Analog",
|
||||||
"funFactBody": "Even though I automate a lot, I still use pen and paper for my calendar and notes – it helps me stay focused.",
|
"funFactBody": "Despite my love for automation and the cloud, my most important ideas are still born on paper with a fountain pen. It's my way of staying grounded.",
|
||||||
"techStackTitle": "My Tech Stack",
|
"techStackTitle": "My Tech Stack",
|
||||||
"hobbiesTitle": "When I'm Not Coding",
|
"hobbiesTitle": "When I'm Not Coding",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
@@ -64,6 +65,19 @@
|
|||||||
"currentlyReading": {
|
"currentlyReading": {
|
||||||
"title": "Currently Reading",
|
"title": "Currently Reading",
|
||||||
"progress": "Progress"
|
"progress": "Progress"
|
||||||
|
},
|
||||||
|
"readBooks": {
|
||||||
|
"title": "Read",
|
||||||
|
"finishedAt": "Finished",
|
||||||
|
"showMore": "{count} more",
|
||||||
|
"showLess": "Show less"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"idleStatus": "System Idle / Mind Active",
|
||||||
|
"codingNow": "Coding Now",
|
||||||
|
"gaming": "Gaming",
|
||||||
|
"listening": "Listening",
|
||||||
|
"inGame": "In Game"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -60,11 +60,27 @@ const nextConfig: NextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "media.discordapp.net",
|
hostname: "media.discordapp.net",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "cms.dk0.dev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "assets.hardcover.app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "dki.one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "images.unsplash.com",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// Webpack configuration
|
// Webpack configuration
|
||||||
webpack: (config, { dev, isServer }) => {
|
webpack: (config, { dev, isServer, webpack }) => {
|
||||||
// Fix for module resolution issues
|
// Fix for module resolution issues
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback,
|
...config.resolve.fallback,
|
||||||
@@ -91,6 +107,14 @@ const nextConfig: NextConfig = {
|
|||||||
maxSize: 200000, // keep chunks <200KB to avoid large-string serialization
|
maxSize: 200000, // keep chunks <200KB to avoid large-string serialization
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Suppress framer-motion source map errors in development
|
||||||
|
config.plugins.push(
|
||||||
|
new webpack.SourceMapDevToolPlugin({
|
||||||
|
filename: "[file].map",
|
||||||
|
exclude: [/framer-motion/, /LayoutGroupContext/],
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +126,9 @@ const nextConfig: NextConfig = {
|
|||||||
const csp =
|
const csp =
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? // Avoid `unsafe-eval` in production (reduces XSS impact and enables stronger CSP)
|
? // Avoid `unsafe-eval` in production (reduces XSS impact and enables stronger CSP)
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https:; connect-src 'self' https://*.dk0.dev; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:;"
|
||||||
: // Dev CSP: allow eval for tooling compatibility
|
: // Dev CSP: allow eval for tooling compatibility, and localhost for HMR/API
|
||||||
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';";
|
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src-elem 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https: http://localhost:3000; connect-src 'self' http://localhost:3000 ws://localhost:3000 https://*.dk0.dev; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; worker-src 'self' blob:;";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
6218
package-lock.json
generated
6218
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,7 @@
|
|||||||
"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",
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
"@types/react-responsive-masonry": "^2.6.0",
|
"@types/react-responsive-masonry": "^2.6.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.11",
|
"@types/react-syntax-highlighter": "^15.5.11",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.24",
|
||||||
"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",
|
||||||
@@ -112,10 +113,10 @@
|
|||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"nodemailer-mock": "^2.0.9",
|
"nodemailer-mock": "^2.0.9",
|
||||||
"playwright": "^1.57.0",
|
"playwright": "^1.57.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.6",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.4.6",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.20.5",
|
"tsx": "^4.20.5",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
66
scripts/atomic-setup-book-reviews.js
Normal file
66
scripts/atomic-setup-book-reviews.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'POST', body = null) {
|
||||||
|
const res = await fetch(`${DIRECTUS_URL}/${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
return res.ok ? await res.json() : { error: true, status: res.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function atomicSetup() {
|
||||||
|
console.log('🚀 Starte atomares Setup...');
|
||||||
|
|
||||||
|
// 1. Die Haupt-Collection mit allen Feldern in EINEM Request
|
||||||
|
const setup = await api('collections', 'POST', {
|
||||||
|
collection: 'book_reviews',
|
||||||
|
schema: {},
|
||||||
|
meta: { icon: 'import_contacts', display_template: '{{book_title}}' },
|
||||||
|
fields: [
|
||||||
|
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||||
|
{ field: 'status', type: 'string', schema: { default_value: 'draft' }, meta: { interface: 'select-dropdown' } },
|
||||||
|
{ field: 'book_title', type: 'string', schema: {}, meta: { interface: 'input' } },
|
||||||
|
{ field: 'book_author', type: 'string', schema: {}, meta: { interface: 'input' } },
|
||||||
|
{ field: 'book_image', type: 'string', schema: {}, meta: { interface: 'input' } },
|
||||||
|
{ field: 'rating', type: 'integer', schema: {}, meta: { interface: 'rating' } },
|
||||||
|
{ field: 'hardcover_id', type: 'string', schema: { is_unique: true }, meta: { interface: 'input' } },
|
||||||
|
{ field: 'finished_at', type: 'date', schema: {}, meta: { interface: 'datetime' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (setup.error) { console.error('Fehler bei Haupt-Collection:', setup); return; }
|
||||||
|
console.log('✅ Haupt-Collection steht.');
|
||||||
|
|
||||||
|
// 2. Die Übersetzungs-Collection
|
||||||
|
await api('collections', 'POST', {
|
||||||
|
collection: 'book_reviews_translations',
|
||||||
|
schema: {},
|
||||||
|
meta: { hidden: true },
|
||||||
|
fields: [
|
||||||
|
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||||
|
{ field: 'book_reviews_id', type: 'integer', schema: {} },
|
||||||
|
{ field: 'languages_code', type: 'string', schema: {} },
|
||||||
|
{ field: 'review', type: 'text', schema: {}, meta: { interface: 'input-rich-text-html' } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
console.log('✅ Übersetzungstabelle steht.');
|
||||||
|
|
||||||
|
// 3. Die Relationen (Der Kleber)
|
||||||
|
await api('relations', 'POST', { collection: 'book_reviews_translations', field: 'book_reviews_id', related_collection: 'book_reviews', meta: { one_field: 'translations' }, schema: { on_delete: 'CASCADE' } });
|
||||||
|
await api('relations', 'POST', { collection: 'book_reviews_translations', field: 'languages_code', related_collection: 'languages', schema: { on_delete: 'SET NULL' } });
|
||||||
|
|
||||||
|
// 4. Das Translations-Feld in der Haupt-Collection registrieren
|
||||||
|
await api('fields/book_reviews', 'POST', {
|
||||||
|
field: 'translations',
|
||||||
|
type: 'alias',
|
||||||
|
meta: { interface: 'translations', special: ['translations'], options: { languageField: 'languages_code' } }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✨ Alles fertig! Bitte lade Directus neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
atomicSetup().catch(console.error);
|
||||||
60
scripts/cleanup-directus-ui.js
Normal file
60
scripts/cleanup-directus-ui.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalCleanup() {
|
||||||
|
console.log('🧹 Räume Directus UI auf...');
|
||||||
|
|
||||||
|
// 1. Haupt-Collection konfigurieren
|
||||||
|
await api('collections/book_reviews', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
icon: 'import_contacts',
|
||||||
|
display_template: '{{book_title}}',
|
||||||
|
hidden: false,
|
||||||
|
group: null, // Aus Ordnern herausholen
|
||||||
|
singleton: false,
|
||||||
|
translations: [
|
||||||
|
{ language: 'de-DE', translation: 'Buch-Bewertungen' },
|
||||||
|
{ language: 'en-US', translation: 'Book Reviews' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Übersetzungs-Tabelle verstecken (Wichtig für die Optik!)
|
||||||
|
await api('collections/book_reviews_translations', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
group: 'book_reviews' // Technisch untergeordnet
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Sicherstellen, dass das 'translations' Feld im CMS gut aussieht
|
||||||
|
await api('fields/book_reviews/translations', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
display: 'translations',
|
||||||
|
options: {
|
||||||
|
languageField: 'languages_code',
|
||||||
|
userLanguage: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ UI optimiert! Bitte lade Directus jetzt neu (Cmd+R / Strg+R).');
|
||||||
|
console.log('Du solltest jetzt links in der Navigation "Book Reviews" mit einem Buch-Icon sehen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
finalCleanup().catch(console.error);
|
||||||
38
scripts/deep-fix-languages.js
Normal file
38
scripts/deep-fix-languages.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deepFixLanguages() {
|
||||||
|
console.log('🏗 Starte Deep-Fix...');
|
||||||
|
const collections = ['projects', 'book_reviews', 'hobbies', 'tech_stack_categories', 'messages'];
|
||||||
|
for (const coll of collections) {
|
||||||
|
const transColl = coll + '_translations';
|
||||||
|
console.log('🛠 Fixe ' + transColl);
|
||||||
|
await api('relations/' + transColl + '/languages_code', 'DELETE').catch(() => {});
|
||||||
|
await api('relations', 'POST', {
|
||||||
|
collection: transColl,
|
||||||
|
field: 'languages_code',
|
||||||
|
related_collection: 'languages',
|
||||||
|
schema: {},
|
||||||
|
meta: { interface: 'select-dropdown', options: { template: '{{name}}' } }
|
||||||
|
});
|
||||||
|
await api('fields/' + transColl + '/languages_code', 'PATCH', {
|
||||||
|
meta: { interface: 'select-dropdown', display: 'raw', required: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ Fertig! Bitte lade Directus neu.');
|
||||||
|
}
|
||||||
|
deepFixLanguages().catch(console.error);
|
||||||
27
scripts/emergency-directus-fix.js
Normal file
27
scripts/emergency-directus-fix.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emergencyFix() {
|
||||||
|
console.log('Fixing...');
|
||||||
|
await api('collections/book_reviews', 'PATCH', { meta: { hidden: true } });
|
||||||
|
await api('collections/book_reviews', 'PATCH', { meta: { hidden: false, icon: 'book' } });
|
||||||
|
await api('fields/book_reviews/translations', 'PATCH', {
|
||||||
|
meta: { interface: 'translations', options: { languageField: 'languages_code' } }
|
||||||
|
});
|
||||||
|
console.log('Done. Please reload in Incognito.');
|
||||||
|
}
|
||||||
|
emergencyFix().catch(console.error);
|
||||||
45
scripts/final-directus-ui-fix.js
Normal file
45
scripts/final-directus-ui-fix.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'PATCH', body = null) {
|
||||||
|
const res = await fetch(`${DIRECTUS_URL}/${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
return res.ok ? await res.json() : { error: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalDirectusUiFix() {
|
||||||
|
console.log('🛠 Finaler UI-Fix für Status und Übersetzungen...');
|
||||||
|
|
||||||
|
// 1. Status-Dropdown Optionen hinzufügen
|
||||||
|
await api('fields/book_reviews/status', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'select-dropdown',
|
||||||
|
options: {
|
||||||
|
choices: [
|
||||||
|
{ text: 'Draft', value: 'draft' },
|
||||||
|
{ text: 'Published', value: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Translations Interface auf Tabs (translations) umstellen
|
||||||
|
await api('fields/book_reviews/translations', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
special: ['translations'],
|
||||||
|
options: {
|
||||||
|
languageField: 'languages_code'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ UI-Einstellungen korrigiert! Bitte lade Directus neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
finalDirectusUiFix().catch(console.error);
|
||||||
55
scripts/fix-auto-id.js
Normal file
55
scripts/fix-auto-id.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'PATCH', body = null) {
|
||||||
|
const res = await fetch(`${DIRECTUS_URL}/${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { ok: res.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixAutoId() {
|
||||||
|
console.log('🛠 Automatisierung der IDs für Übersetzungen...');
|
||||||
|
|
||||||
|
// 1. ID Feld in der Übersetzungstabelle konfigurieren
|
||||||
|
await api('fields/book_reviews_translations/id', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
interface: 'input',
|
||||||
|
readonly: true,
|
||||||
|
special: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Fremdschlüssel (book_reviews_id) verstecken, da Directus das intern regelt
|
||||||
|
await api('fields/book_reviews_translations/book_reviews_id', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
interface: 'input',
|
||||||
|
readonly: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Sprach-Code Feld konfigurieren
|
||||||
|
await api('fields/book_reviews_translations/languages_code', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'select-dropdown',
|
||||||
|
width: 'half',
|
||||||
|
options: {
|
||||||
|
choices: [
|
||||||
|
{ text: 'Deutsch', value: 'de-DE' },
|
||||||
|
{ text: 'English', value: 'en-US' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Fertig! Die IDs werden nun automatisch im Hintergrund verwaltet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fixAutoId().catch(console.error);
|
||||||
64
scripts/fix-book-reviews-ui.js
Normal file
64
scripts/fix-book-reviews-ui.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
return response.ok ? await response.json() : { error: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixUI() {
|
||||||
|
console.log('🔧 Repariere Directus UI für Book Reviews...');
|
||||||
|
|
||||||
|
// 1. Status Feld verschönern
|
||||||
|
await api('fields/book_reviews/status', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'select-dropdown',
|
||||||
|
display: 'labels',
|
||||||
|
display_options: {
|
||||||
|
showAsDot: true,
|
||||||
|
choices: [
|
||||||
|
{ value: 'published', foreground: '#FFFFFF', background: '#00C897' },
|
||||||
|
{ value: 'draft', foreground: '#FFFFFF', background: '#666666' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
choices: [
|
||||||
|
{ text: 'Veröffentlicht', value: 'published' },
|
||||||
|
{ text: 'Entwurf', value: 'draft' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Sprachen-Verknüpfung reparieren (WICHTIG für Tabs)
|
||||||
|
await api('relations', 'POST', {
|
||||||
|
collection: 'book_reviews_translations',
|
||||||
|
field: 'languages_code',
|
||||||
|
related_collection: 'languages',
|
||||||
|
meta: { interface: 'select-dropdown' }
|
||||||
|
}).catch(() => console.log('Relation existiert evtl. schon...'));
|
||||||
|
|
||||||
|
// 3. Übersetzungs-Interface aktivieren
|
||||||
|
await api('fields/book_reviews/translations', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
display: 'translations',
|
||||||
|
options: {
|
||||||
|
languageField: 'languages_code',
|
||||||
|
userLanguage: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ UI-Fix angewendet! Bitte lade Directus neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fixUI().catch(console.error);
|
||||||
37
scripts/fix-directus-book-reviews.js
Normal file
37
scripts/fix-directus-book-reviews.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
return { ok: response.ok, data, status: response.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fix() {
|
||||||
|
console.log('🔧 Fixing Directus Book Reviews...');
|
||||||
|
await api('collections/book_reviews', 'PATCH', { meta: { icon: 'menu_book', display_template: '{{book_title}}', hidden: false } });
|
||||||
|
|
||||||
|
// Link to system languages
|
||||||
|
await api('relations', 'POST', {
|
||||||
|
collection: 'book_reviews_translations',
|
||||||
|
field: 'languages_code',
|
||||||
|
related_collection: 'languages',
|
||||||
|
meta: { interface: 'select-dropdown' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI Improvements
|
||||||
|
await api('fields/book_reviews/status', 'PATCH', { meta: { interface: 'select-dropdown', display: 'labels' } });
|
||||||
|
await api('fields/book_reviews/rating', 'PATCH', { meta: { interface: 'rating', display: 'rating' } });
|
||||||
|
await api('fields/book_reviews_translations/review', 'PATCH', { meta: { interface: 'input-rich-text-html' } });
|
||||||
|
|
||||||
|
console.log('✅ Fix applied! Bitte lade Directus neu und setze die Permissions auf Public.');
|
||||||
|
}
|
||||||
|
fix().catch(console.error);
|
||||||
53
scripts/fix-messages-collection.js
Normal file
53
scripts/fix-messages-collection.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'POST', body = null) {
|
||||||
|
const res = await fetch(`${DIRECTUS_URL}/${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return { ok: res.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixMessagesCollection() {
|
||||||
|
console.log('🛠 Repariere "messages" Collection...');
|
||||||
|
|
||||||
|
// 1. Key-Feld hinzufügen (falls es fehlt)
|
||||||
|
// Wir nutzen type: string und schema: {} um eine echte Spalte zu erzeugen
|
||||||
|
const fieldRes = await api('fields/messages', 'POST', {
|
||||||
|
field: 'key',
|
||||||
|
type: 'string',
|
||||||
|
schema: {
|
||||||
|
is_nullable: false,
|
||||||
|
is_unique: true
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
interface: 'input',
|
||||||
|
options: { placeholder: 'z.B. hero.title' },
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fieldRes.ok) {
|
||||||
|
console.log('✅ "key" Feld erfolgreich erstellt.');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ "key" Feld konnte nicht erstellt werden (existiert evtl schon).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Übersetzungs-Feld in der Untertabelle reparieren
|
||||||
|
console.log('🛠 Prüfe messages_translations...');
|
||||||
|
await api('fields/messages_translations', 'POST', {
|
||||||
|
field: 'value',
|
||||||
|
type: 'text',
|
||||||
|
schema: {},
|
||||||
|
meta: { interface: 'input-multiline' }
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
console.log('✅ Fix abgeschlossen! Bitte lade Directus neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fixMessagesCollection().catch(console.error);
|
||||||
51
scripts/fix-relations-metadata.js
Normal file
51
scripts/fix-relations-metadata.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixRelationsMetadata() {
|
||||||
|
console.log('🔗 Fixe Sprach-Relationen Metadaten...');
|
||||||
|
|
||||||
|
const collections = ['projects', 'book_reviews', 'hobbies', 'tech_stack_categories', 'messages'];
|
||||||
|
|
||||||
|
for (const coll of collections) {
|
||||||
|
const transColl = `${coll}_translations`;
|
||||||
|
console.log(`🛠 Konfiguriere ${transColl}...`);
|
||||||
|
|
||||||
|
// Wir müssen die Relation von languages_code zur languages Tabelle
|
||||||
|
// für Directus "greifbar" machen.
|
||||||
|
await api(`relations/${transColl}/languages_code`, 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'select-dropdown',
|
||||||
|
display: 'raw'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// WICHTIG: Wir sagen dem Hauptfeld "translations" noch einmal
|
||||||
|
// ganz explizit, welches Feld in der Untertabelle für die Sprache zuständig ist.
|
||||||
|
await api(`fields/${coll}/translations`, 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
options: {
|
||||||
|
languageField: 'languages_code' // Der Name des Feldes in der *_translations Tabelle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Fertig! Bitte lade Directus neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fixRelationsMetadata().catch(console.error);
|
||||||
54
scripts/fix-translation-interface.js
Normal file
54
scripts/fix-translation-interface.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixTranslationInterface() {
|
||||||
|
console.log('🛠 Erdenke das Translations-Interface neu...');
|
||||||
|
|
||||||
|
const collections = ['projects', 'book_reviews', 'hobbies', 'tech_stack_categories', 'messages'];
|
||||||
|
|
||||||
|
for (const coll of collections) {
|
||||||
|
console.log(`🔧 Fixe Interface für ${coll}...`);
|
||||||
|
|
||||||
|
// Wir überschreiben die Metadaten des Feldes "translations"
|
||||||
|
// WICHTIG: Wir setzen interface auf 'translations' und mappen das languageField
|
||||||
|
await api(`fields/${coll}/translations`, 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
display: 'translations',
|
||||||
|
special: ['translations'],
|
||||||
|
options: {
|
||||||
|
languageField: 'languages_code',
|
||||||
|
userLanguage: true,
|
||||||
|
defaultLanguage: 'de-DE'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wir stellen sicher, dass in der Untertabelle das Feld languages_code
|
||||||
|
// als 'languages' Typ erkannt wird
|
||||||
|
await api(`fields/${coll}_translations/languages_code`, 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'select-dropdown',
|
||||||
|
special: null // Kein spezielles Feld hier, nur ein normaler FK
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Übersetzung-Tabs sollten jetzt erscheinen! Bitte Directus hart neu laden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
fixTranslationInterface().catch(console.error);
|
||||||
63
scripts/force-translations-plus.js
Normal file
63
scripts/force-translations-plus.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function forceTranslationsPlusButton() {
|
||||||
|
console.log('🔨 Erzwinge "Plus"-Button für Übersetzungen...');
|
||||||
|
|
||||||
|
const coll = 'book_reviews';
|
||||||
|
const transColl = 'book_reviews_translations';
|
||||||
|
|
||||||
|
// 1. Das alte Alias-Feld löschen (falls es klemmt)
|
||||||
|
await api(`fields/${coll}/translations`, 'DELETE').catch(() => {});
|
||||||
|
|
||||||
|
// 2. Das Feld komplett neu anlegen als technisches Alias für die Relation
|
||||||
|
await api(`fields/${coll}`, 'POST', {
|
||||||
|
field: 'translations',
|
||||||
|
type: 'alias',
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
display: 'translations',
|
||||||
|
special: ['translations'],
|
||||||
|
options: {
|
||||||
|
languageField: 'languages_code',
|
||||||
|
userLanguage: true
|
||||||
|
},
|
||||||
|
width: 'full'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Die Relation explizit als One-to-Many (O2M) registrieren
|
||||||
|
// Das ist der wichtigste Schritt für den Plus-Button!
|
||||||
|
await api('relations', 'POST', {
|
||||||
|
collection: transColl,
|
||||||
|
field: 'book_reviews_id',
|
||||||
|
related_collection: coll,
|
||||||
|
meta: {
|
||||||
|
one_field: 'translations',
|
||||||
|
junction_field: null,
|
||||||
|
one_deselect_action: 'delete'
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
on_delete: 'CASCADE'
|
||||||
|
}
|
||||||
|
}).catch(err => console.log('Relation existiert evtl. schon, überspringe...'));
|
||||||
|
|
||||||
|
console.log('✅ Fertig! Bitte lade Directus neu.');
|
||||||
|
console.log('Gehe in ein Buch -> Jetzt MUSS unten bei "Translations" ein Plus-Button oder "Create New" stehen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
forceTranslationsPlusButton().catch(console.error);
|
||||||
46
scripts/global-cms-beauty-fix.js
Normal file
46
scripts/global-cms-beauty-fix.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function globalBeautyFix() {
|
||||||
|
console.log('✨ Starte globale CMS-Verschönerung...');
|
||||||
|
await api('items/languages', 'POST', { code: 'de-DE', name: 'German' }).catch(() => {});
|
||||||
|
await api('items/languages', 'POST', { code: 'en-US', name: 'English' }).catch(() => {});
|
||||||
|
|
||||||
|
const collections = ['projects', 'book_reviews', 'hobbies', 'tech_stack_categories', 'messages'];
|
||||||
|
for (const coll of collections) {
|
||||||
|
console.log('📦 Optimiere ' + coll);
|
||||||
|
await api('collections/' + coll + '_translations', 'PATCH', { meta: { hidden: true } });
|
||||||
|
await api('fields/' + coll + '/translations', 'PATCH', {
|
||||||
|
meta: {
|
||||||
|
interface: 'translations',
|
||||||
|
display: 'translations',
|
||||||
|
width: 'full',
|
||||||
|
options: { languageField: 'languages_code', defaultLanguage: 'de-DE', userLanguage: true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await api('relations', 'POST', {
|
||||||
|
collection: coll + '_translations',
|
||||||
|
field: 'languages_code',
|
||||||
|
related_collection: 'languages',
|
||||||
|
meta: { interface: 'select-dropdown' }
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
await api('fields/projects/tags', 'PATCH', { meta: { interface: 'tags' } });
|
||||||
|
await api('fields/projects/technologies', 'PATCH', { meta: { interface: 'tags' } });
|
||||||
|
console.log('✅ CMS ist jetzt aufgeräumt! Bitte Directus neu laden.');
|
||||||
|
}
|
||||||
|
globalBeautyFix().catch(console.error);
|
||||||
55
scripts/make-directus-editable.js
Normal file
55
scripts/make-directus-editable.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeEditable() {
|
||||||
|
console.log('🔓 Mache "Book Reviews" editierbar...');
|
||||||
|
|
||||||
|
// 1. Prüfen ob ID Feld existiert, sonst anlegen
|
||||||
|
console.log('1. Erstelle ID-Primärschlüssel...');
|
||||||
|
await api('fields/book_reviews', 'POST', {
|
||||||
|
field: 'id',
|
||||||
|
type: 'integer',
|
||||||
|
schema: {
|
||||||
|
is_primary_key: true,
|
||||||
|
has_auto_increment: true
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
hidden: true // Im Formular verstecken, da automatisch
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Sicherstellen, dass alle Felder eine Interface-Zuweisung haben (wichtig für die Eingabe)
|
||||||
|
console.log('2. Konfiguriere Eingabe-Interfaces...');
|
||||||
|
const fieldUpdates = [
|
||||||
|
{ field: 'book_title', interface: 'input' },
|
||||||
|
{ field: 'book_author', interface: 'input' },
|
||||||
|
{ field: 'rating', interface: 'rating' },
|
||||||
|
{ field: 'status', interface: 'select-dropdown' },
|
||||||
|
{ field: 'finished_at', interface: 'datetime' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of fieldUpdates) {
|
||||||
|
await api(`fields/book_reviews/${f.field}`, 'PATCH', {
|
||||||
|
meta: { interface: f.interface, readonly: false }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Fertig! Bitte lade Directus neu.');
|
||||||
|
console.log('Solltest du immer noch nicht editieren können, musst du eventuell die Collection löschen und neu anlegen lassen, da die Datenbank-Struktur (ID) manchmal nicht nachträglich über die API geändert werden kann.');
|
||||||
|
}
|
||||||
|
|
||||||
|
makeEditable().catch(console.error);
|
||||||
46
scripts/master-setup-book-reviews.js
Normal file
46
scripts/master-setup-book-reviews.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function masterSetup() {
|
||||||
|
console.log('🚀 Starte Master-Setup...');
|
||||||
|
await api('collections', 'POST', { collection: 'book_reviews', meta: { icon: 'import_contacts', display_template: '{{book_title}}' } });
|
||||||
|
await api('fields/book_reviews', 'POST', { field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true }, meta: { hidden: true } });
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ field: 'status', type: 'string', meta: { interface: 'select-dropdown', options: { choices: [{text: 'Published', value: 'published'}, {text: 'Draft', value: 'draft'}] } }, schema: { default_value: 'draft' } },
|
||||||
|
{ field: 'book_title', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'book_author', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'book_image', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'rating', type: 'integer', meta: { interface: 'rating' } },
|
||||||
|
{ field: 'hardcover_id', type: 'string', meta: { interface: 'input' }, schema: { is_unique: true } },
|
||||||
|
{ field: 'finished_at', type: 'date', meta: { interface: 'datetime' } }
|
||||||
|
];
|
||||||
|
for (const f of fields) await api('fields/book_reviews', 'POST', f);
|
||||||
|
|
||||||
|
await api('collections', 'POST', { collection: 'book_reviews_translations', meta: { hidden: true } });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true }, meta: { hidden: true } });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'book_reviews_id', type: 'integer' });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'languages_code', type: 'string' });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'review', type: 'text', meta: { interface: 'input-rich-text-html' } });
|
||||||
|
|
||||||
|
await api('relations', 'POST', { collection: 'book_reviews_translations', field: 'book_reviews_id', related_collection: 'book_reviews', meta: { one_field: 'translations' } });
|
||||||
|
await api('relations', 'POST', { collection: 'book_reviews_translations', field: 'languages_code', related_collection: 'languages' });
|
||||||
|
await api('fields/book_reviews', 'POST', { field: 'translations', type: 'alias', meta: { interface: 'translations', special: ['translations'] } });
|
||||||
|
|
||||||
|
console.log('✨ Setup abgeschlossen! Bitte lade Directus neu und setze die Public-Permissions.');
|
||||||
|
}
|
||||||
|
masterSetup().catch(console.error);
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Migrate Content Pages from PostgreSQL (Prisma) to Directus
|
|
||||||
*
|
|
||||||
* - Copies `content_pages` + translations from Postgres into Directus
|
|
||||||
* - Creates or updates items per (slug, locale)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* DATABASE_URL=postgresql://... DIRECTUS_STATIC_TOKEN=... DIRECTUS_URL=... \
|
|
||||||
* node scripts/migrate-content-pages-to-directus.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
|
||||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
|
||||||
|
|
||||||
if (!DIRECTUS_TOKEN) {
|
|
||||||
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in env');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
const localeMap = {
|
|
||||||
en: 'en-US',
|
|
||||||
de: 'de-DE',
|
|
||||||
};
|
|
||||||
|
|
||||||
function toDirectusLocale(locale) {
|
|
||||||
return localeMap[locale] || locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function directusRequest(endpoint, method = 'GET', body = null) {
|
|
||||||
const url = `${DIRECTUS_URL}/${endpoint}`;
|
|
||||||
const options = {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body) {
|
|
||||||
options.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, options);
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`HTTP ${res.status} on ${endpoint}: ${text}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertContentIntoDirectus({ slug, locale, status, title, content }) {
|
|
||||||
const directusLocale = toDirectusLocale(locale);
|
|
||||||
|
|
||||||
// allow locale-specific slug variants: base for en, base-locale for others
|
|
||||||
const slugVariant = directusLocale === 'en-US' ? slug : `${slug}-${directusLocale.toLowerCase()}`;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
slug: slugVariant,
|
|
||||||
locale: directusLocale,
|
|
||||||
status: status?.toLowerCase?.() === 'published' ? 'published' : status || 'draft',
|
|
||||||
title: title || slug,
|
|
||||||
content: content || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await directusRequest('items/content_pages', 'POST', payload);
|
|
||||||
console.log(` ➕ Created ${slugVariant} (${directusLocale}) [id=${data?.id}]`);
|
|
||||||
return data?.id;
|
|
||||||
} catch (error) {
|
|
||||||
const msg = error?.message || '';
|
|
||||||
if (msg.includes('already exists') || msg.includes('duplicate key') || msg.includes('UNIQUE')) {
|
|
||||||
console.log(` ⚠️ Skipping ${slugVariant} (${directusLocale}) – already exists`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateContentPages() {
|
|
||||||
console.log('\n📦 Migrating Content Pages from PostgreSQL to Directus...');
|
|
||||||
|
|
||||||
const pages = await prisma.contentPage.findMany({
|
|
||||||
include: { translations: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Found ${pages.length} pages in PostgreSQL`);
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
const status = page.status || 'PUBLISHED';
|
|
||||||
for (const tr of page.translations) {
|
|
||||||
await upsertContentIntoDirectus({
|
|
||||||
slug: page.key,
|
|
||||||
locale: tr.locale,
|
|
||||||
status,
|
|
||||||
title: tr.title,
|
|
||||||
content: tr.content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Content page migration finished.');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
|
||||||
await prisma.$connect();
|
|
||||||
await migrateContentPages();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Migration failed:', error.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Migrate Hobbies to Directus
|
|
||||||
*
|
|
||||||
* Migriert Hobbies-Daten aus messages/en.json und messages/de.json nach Directus
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/migrate-hobbies-to-directus.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
|
||||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
|
||||||
|
|
||||||
if (!DIRECTUS_TOKEN) {
|
|
||||||
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const messagesEn = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(__dirname, '../messages/en.json'), 'utf-8')
|
|
||||||
);
|
|
||||||
const messagesDe = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(__dirname, '../messages/de.json'), 'utf-8')
|
|
||||||
);
|
|
||||||
|
|
||||||
const hobbiesEn = messagesEn.home.about.hobbies;
|
|
||||||
const hobbiesDe = messagesDe.home.about.hobbies;
|
|
||||||
|
|
||||||
const HOBBIES_DATA = [
|
|
||||||
{
|
|
||||||
key: 'self_hosting',
|
|
||||||
icon: 'Code',
|
|
||||||
titleEn: hobbiesEn.selfHosting,
|
|
||||||
titleDe: hobbiesDe.selfHosting
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'gaming',
|
|
||||||
icon: 'Gamepad2',
|
|
||||||
titleEn: hobbiesEn.gaming,
|
|
||||||
titleDe: hobbiesDe.gaming
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'game_servers',
|
|
||||||
icon: 'Server',
|
|
||||||
titleEn: hobbiesEn.gameServers,
|
|
||||||
titleDe: hobbiesDe.gameServers
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'jogging',
|
|
||||||
icon: 'Activity',
|
|
||||||
titleEn: hobbiesEn.jogging,
|
|
||||||
titleDe: hobbiesDe.jogging
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
async function directusRequest(endpoint, method = 'GET', body = null) {
|
|
||||||
const url = `${DIRECTUS_URL}/${endpoint}`;
|
|
||||||
const options = {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body) {
|
|
||||||
options.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error calling ${method} ${endpoint}:`, error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateHobbies() {
|
|
||||||
console.log('\n📦 Migrating Hobbies to Directus...\n');
|
|
||||||
|
|
||||||
for (const hobby of HOBBIES_DATA) {
|
|
||||||
console.log(`\n🎮 Hobby: ${hobby.key}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Create Hobby
|
|
||||||
console.log(' Creating hobby...');
|
|
||||||
const hobbyData = {
|
|
||||||
key: hobby.key,
|
|
||||||
icon: hobby.icon,
|
|
||||||
status: 'published',
|
|
||||||
sort: HOBBIES_DATA.indexOf(hobby) + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: createdHobby } = await directusRequest(
|
|
||||||
'items/hobbies',
|
|
||||||
'POST',
|
|
||||||
hobbyData
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(` ✅ Hobby created with ID: ${createdHobby.id}`);
|
|
||||||
|
|
||||||
// 2. Create Translations
|
|
||||||
console.log(' Creating translations...');
|
|
||||||
|
|
||||||
// English Translation
|
|
||||||
await directusRequest(
|
|
||||||
'items/hobbies_translations',
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
hobbies_id: createdHobby.id,
|
|
||||||
languages_code: 'en-US',
|
|
||||||
title: hobby.titleEn
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// German Translation
|
|
||||||
await directusRequest(
|
|
||||||
'items/hobbies_translations',
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
hobbies_id: createdHobby.id,
|
|
||||||
languages_code: 'de-DE',
|
|
||||||
title: hobby.titleDe
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(' ✅ Translations created (en-US, de-DE)');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` ❌ Error migrating ${hobby.key}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✨ Migration complete!\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyMigration() {
|
|
||||||
console.log('\n🔍 Verifying Migration...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: hobbies } = await directusRequest(
|
|
||||||
'items/hobbies?fields=key,icon,status,translations.title,translations.languages_code'
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ Found ${hobbies.length} hobbies in Directus:`);
|
|
||||||
hobbies.forEach(h => {
|
|
||||||
const enTitle = h.translations?.find(t => t.languages_code === 'en-US')?.title;
|
|
||||||
console.log(` - ${h.key}: "${enTitle}"`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n🎉 Hobbies successfully migrated!\n');
|
|
||||||
console.log('Next steps:');
|
|
||||||
console.log(' 1. Visit: https://cms.dk0.dev/admin/content/hobbies');
|
|
||||||
console.log(' 2. Update About.tsx to load hobbies from Directus\n');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Verification failed:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('\n╔════════════════════════════════════════╗');
|
|
||||||
console.log('║ Hobbies Migration to Directus ║');
|
|
||||||
console.log('╚════════════════════════════════════════╝\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await migrateHobbies();
|
|
||||||
await verifyMigration();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n❌ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Directus Tech Stack Migration Script
|
|
||||||
*
|
|
||||||
* Migriert bestehende Tech Stack Daten aus messages/en.json und messages/de.json
|
|
||||||
* nach Directus Collections.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* npm install node-fetch@2 dotenv
|
|
||||||
* node scripts/migrate-tech-stack-to-directus.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fetch = require('node-fetch');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
|
||||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
|
||||||
|
|
||||||
if (!DIRECTUS_TOKEN) {
|
|
||||||
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lade aktuelle Tech Stack Daten aus messages files
|
|
||||||
const messagesEn = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(__dirname, '../messages/en.json'), 'utf-8')
|
|
||||||
);
|
|
||||||
const messagesDe = JSON.parse(
|
|
||||||
fs.readFileSync(path.join(__dirname, '../messages/de.json'), 'utf-8')
|
|
||||||
);
|
|
||||||
|
|
||||||
const techStackEn = messagesEn.home.about.techStack;
|
|
||||||
const techStackDe = messagesDe.home.about.techStack;
|
|
||||||
|
|
||||||
// Tech Stack Struktur aus About.tsx
|
|
||||||
const TECH_STACK_DATA = [
|
|
||||||
{
|
|
||||||
key: 'frontend',
|
|
||||||
icon: 'Globe',
|
|
||||||
nameEn: techStackEn.categories.frontendMobile,
|
|
||||||
nameDe: techStackDe.categories.frontendMobile,
|
|
||||||
items: ['Next.js', 'Tailwind CSS', 'Flutter']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'backend',
|
|
||||||
icon: 'Server',
|
|
||||||
nameEn: techStackEn.categories.backendDevops,
|
|
||||||
nameDe: techStackDe.categories.backendDevops,
|
|
||||||
items: ['Docker', 'PostgreSQL', 'Redis', 'Traefik']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tools',
|
|
||||||
icon: 'Wrench',
|
|
||||||
nameEn: techStackEn.categories.toolsAutomation,
|
|
||||||
nameDe: techStackDe.categories.toolsAutomation,
|
|
||||||
items: ['Git', 'CI/CD', 'n8n', techStackEn.items.selfHostedServices]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'security',
|
|
||||||
icon: 'Shield',
|
|
||||||
nameEn: techStackEn.categories.securityAdmin,
|
|
||||||
nameDe: techStackDe.categories.securityAdmin,
|
|
||||||
items: ['CrowdSec', 'Suricata', 'Proxmox']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
async function directusRequest(endpoint, method = 'GET', body = null) {
|
|
||||||
const url = `${DIRECTUS_URL}/${endpoint}`;
|
|
||||||
const options = {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body) {
|
|
||||||
options.body = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, options);
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error calling ${method} ${endpoint}:`, error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureLanguagesExist() {
|
|
||||||
console.log('\n🌍 Checking Languages...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: languages } = await directusRequest('items/languages');
|
|
||||||
const hasEnUS = languages.some(l => l.code === 'en-US');
|
|
||||||
const hasDeDE = languages.some(l => l.code === 'de-DE');
|
|
||||||
|
|
||||||
if (!hasEnUS) {
|
|
||||||
console.log(' Creating en-US language...');
|
|
||||||
await directusRequest('items/languages', 'POST', {
|
|
||||||
code: 'en-US',
|
|
||||||
name: 'English (United States)'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasDeDE) {
|
|
||||||
console.log(' Creating de-DE language...');
|
|
||||||
await directusRequest('items/languages', 'POST', {
|
|
||||||
code: 'de-DE',
|
|
||||||
name: 'German (Germany)'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(' ✅ Languages ready');
|
|
||||||
} catch (error) {
|
|
||||||
console.log(' ⚠️ Languages collection might not exist yet');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function migrateTechStack() {
|
|
||||||
console.log('\n📦 Migrating Tech Stack to Directus...\n');
|
|
||||||
|
|
||||||
await ensureLanguagesExist();
|
|
||||||
|
|
||||||
for (const category of TECH_STACK_DATA) {
|
|
||||||
console.log(`\n📁 Category: ${category.key}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Create Category
|
|
||||||
console.log(' Creating category...');
|
|
||||||
const categoryData = {
|
|
||||||
key: category.key,
|
|
||||||
icon: category.icon,
|
|
||||||
status: 'published',
|
|
||||||
sort: TECH_STACK_DATA.indexOf(category) + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: createdCategory } = await directusRequest(
|
|
||||||
'items/tech_stack_categories',
|
|
||||||
'POST',
|
|
||||||
categoryData
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(` ✅ Category created with ID: ${createdCategory.id}`);
|
|
||||||
|
|
||||||
// 2. Create Translations
|
|
||||||
console.log(' Creating translations...');
|
|
||||||
|
|
||||||
// English Translation
|
|
||||||
await directusRequest(
|
|
||||||
'items/tech_stack_categories_translations',
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
tech_stack_categories_id: createdCategory.id,
|
|
||||||
languages_code: 'en-US',
|
|
||||||
name: category.nameEn
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// German Translation
|
|
||||||
await directusRequest(
|
|
||||||
'items/tech_stack_categories_translations',
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
tech_stack_categories_id: createdCategory.id,
|
|
||||||
languages_code: 'de-DE',
|
|
||||||
name: category.nameDe
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(' ✅ Translations created (en-US, de-DE)');
|
|
||||||
|
|
||||||
// 3. Create Items
|
|
||||||
console.log(` Creating ${category.items.length} items...`);
|
|
||||||
|
|
||||||
for (let i = 0; i < category.items.length; i++) {
|
|
||||||
const itemName = category.items[i];
|
|
||||||
await directusRequest(
|
|
||||||
'items/tech_stack_items',
|
|
||||||
'POST',
|
|
||||||
{
|
|
||||||
category: createdCategory.id,
|
|
||||||
name: itemName,
|
|
||||||
sort: i + 1
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log(` ✅ ${itemName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(` ❌ Error migrating ${category.key}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✨ Migration complete!\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyMigration() {
|
|
||||||
console.log('\n🔍 Verifying Migration...\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data: categories } = await directusRequest(
|
|
||||||
'items/tech_stack_categories?fields=*,translations.*,items.*'
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ Found ${categories.length} categories:`);
|
|
||||||
categories.forEach(cat => {
|
|
||||||
const enTranslation = cat.translations?.find(t => t.languages_code === 'en-US');
|
|
||||||
const itemCount = cat.items?.length || 0;
|
|
||||||
console.log(` - ${cat.key}: "${enTranslation?.name}" (${itemCount} items)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n🎉 All data migrated successfully!\n');
|
|
||||||
console.log('Next steps:');
|
|
||||||
console.log(' 1. Visit https://cms.dk0.dev/admin/content/tech_stack_categories');
|
|
||||||
console.log(' 2. Verify data looks correct');
|
|
||||||
console.log(' 3. Run: npm run dev:directus (to test GraphQL queries)');
|
|
||||||
console.log(' 4. Update About.tsx to use Directus data\n');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Verification failed:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main execution
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await migrateTechStack();
|
|
||||||
await verifyMigration();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('\n❌ Migration failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
51
scripts/perfect-directus-structure.js
Normal file
51
scripts/perfect-directus-structure.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
return { ok: response.ok, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function perfectStructure() {
|
||||||
|
console.log('💎 Optimiere CMS-Struktur zur Perfektion...');
|
||||||
|
|
||||||
|
// 1. Projekt-Buttons individualisieren
|
||||||
|
console.log('1. Erweitere Projekte um individuelle Button-Labels...');
|
||||||
|
await api('fields/projects_translations', 'POST', { field: 'button_live_label', type: 'string', meta: { interface: 'input', options: { placeholder: 'z.B. Live Demo, App öffnen...' }, width: 'half' } });
|
||||||
|
await api('fields/projects_translations', 'POST', { field: 'button_github_label', type: 'string', meta: { interface: 'input', options: { placeholder: 'z.B. Source Code, GitHub...' }, width: 'half' } });
|
||||||
|
|
||||||
|
// 2. SEO für Inhaltsseiten
|
||||||
|
console.log('2. Füge SEO-Felder zu Content-Pages hinzu...');
|
||||||
|
await api('fields/content_pages', 'POST', { field: 'meta_description', type: 'string', meta: { interface: 'input' } });
|
||||||
|
await api('fields/content_pages', 'POST', { field: 'keywords', type: 'string', meta: { interface: 'input' } });
|
||||||
|
|
||||||
|
// 3. Die ultimative "Messages" Collection (für UI Strings)
|
||||||
|
console.log('3. Erstelle globale "Messages" Collection...');
|
||||||
|
await api('collections', 'POST', { collection: 'messages', schema: {}, meta: { icon: 'translate', display_template: '{{key}}' } });
|
||||||
|
await api('fields/messages', 'POST', { field: 'key', type: 'string', schema: { is_primary_key: true }, meta: { interface: 'input', options: { placeholder: 'home.hero.title' } } });
|
||||||
|
|
||||||
|
// Messages Translations
|
||||||
|
await api('collections', 'POST', { collection: 'messages_translations', schema: {}, meta: { hidden: true } });
|
||||||
|
await api('fields/messages_translations', 'POST', { field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true }, meta: { hidden: true } });
|
||||||
|
await api('fields/messages_translations', 'POST', { field: 'messages_id', type: 'string', schema: {} });
|
||||||
|
await api('fields/messages_translations', 'POST', { field: 'languages_code', type: 'string', schema: {} });
|
||||||
|
await api('fields/messages_translations', 'POST', { field: 'value', type: 'text', meta: { interface: 'input' } });
|
||||||
|
|
||||||
|
// Relationen für Messages
|
||||||
|
await api('relations', 'POST', { collection: 'messages_translations', field: 'messages_id', related_collection: 'messages', meta: { one_field: 'translations' } });
|
||||||
|
await api('relations', 'POST', { collection: 'messages_translations', field: 'languages_code', related_collection: 'languages' });
|
||||||
|
await api('fields/messages', 'POST', { field: 'translations', type: 'alias', meta: { interface: 'translations', special: ['translations'] } });
|
||||||
|
|
||||||
|
console.log('✨ CMS Struktur ist jetzt perfekt! Lade Directus neu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
perfectStructure().catch(console.error);
|
||||||
37
scripts/seed-cms-content.js
Normal file
37
scripts/seed-cms-content.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'POST', body = null) {
|
||||||
|
const res = await fetch(`${DIRECTUS_URL}/${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
return res.ok ? await res.json() : { error: true, status: res.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedData = [
|
||||||
|
{ key: 'hero.badge', de: 'Student & Self-Hoster', en: 'Student & Self-Hoster' },
|
||||||
|
{ key: 'hero.line1', de: 'Building', en: 'Building' },
|
||||||
|
{ key: 'hero.line2', de: 'Stuff.', en: 'Stuff.' },
|
||||||
|
{ key: 'about.quote.idle', de: 'Gerade am Planen des nächsten großen Projekts.', en: 'Currently planning the next big thing.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedMessages() {
|
||||||
|
console.log('🌱 Befülle Directus mit Inhalten...');
|
||||||
|
for (const item of seedData) {
|
||||||
|
console.log(`- Erstelle Key: ${item.key}`);
|
||||||
|
const res = await api('items/messages', 'POST', {
|
||||||
|
key: item.key,
|
||||||
|
translations: [
|
||||||
|
{ languages_code: 'de-DE', value: item.de },
|
||||||
|
{ languages_code: 'en-US', value: item.en }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('✅ CMS erfolgreich befüllt!');
|
||||||
|
}
|
||||||
|
|
||||||
|
seedMessages().catch(console.error);
|
||||||
37
scripts/set-public-permissions.js
Normal file
37
scripts/set-public-permissions.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
require('dotenv').config();
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function setPublicPermissions() {
|
||||||
|
console.log('🔓 Setze Public-Berechtigungen für Book Reviews...');
|
||||||
|
|
||||||
|
// Wir holen die ID der Public Rolle
|
||||||
|
const rolesRes = await fetch(`${DIRECTUS_URL}/roles`, { headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}` } });
|
||||||
|
const roles = await rolesRes.json();
|
||||||
|
const publicRole = roles.data.find(r => r.name.toLowerCase() === 'public');
|
||||||
|
|
||||||
|
if (!publicRole) return console.error('Public Rolle nicht gefunden.');
|
||||||
|
|
||||||
|
const collections = ['book_reviews', 'book_reviews_translations'];
|
||||||
|
|
||||||
|
for (const coll of collections) {
|
||||||
|
console.log(`- Erlaube Lesezugriff auf ${coll}`);
|
||||||
|
await fetch(`${DIRECTUS_URL}/permissions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
role: publicRole.id,
|
||||||
|
collection: coll,
|
||||||
|
action: 'read',
|
||||||
|
permissions: {},
|
||||||
|
validation: null,
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Fertig! Die Website sollte die Daten jetzt lesen können.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicPermissions().catch(console.error);
|
||||||
83
scripts/setup-directus-book-reviews.js
Normal file
83
scripts/setup-directus-book-reviews.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Setup Book Reviews Collection in Directus
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
if (!DIRECTUS_TOKEN) {
|
||||||
|
console.error('❌ Error: DIRECTUS_STATIC_TOKEN not found in .env');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(endpoint, method = 'GET', body = null) {
|
||||||
|
const url = `${DIRECTUS_URL}/${endpoint}`;
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${DIRECTUS_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (body) options.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
if (data.errors?.[0]?.extensions?.code === 'RECORD_NOT_UNIQUE' || data.errors?.[0]?.message?.includes('already exists')) {
|
||||||
|
return { alreadyExists: true };
|
||||||
|
}
|
||||||
|
return { error: true, message: data.errors?.[0]?.message };
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: true, message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
console.log('🚀 Starting Directus Book Reviews Setup...');
|
||||||
|
|
||||||
|
const coll = await api('collections', 'POST', {
|
||||||
|
collection: 'book_reviews',
|
||||||
|
meta: { icon: 'menu_book', display_template: '{{book_title}}' }
|
||||||
|
});
|
||||||
|
console.log(coll.alreadyExists ? ' ⚠️ Collection exists.' : ' ✅ Collection created.');
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ field: 'status', type: 'string', meta: { interface: 'select-dropdown' } },
|
||||||
|
{ field: 'book_title', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'book_author', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'book_image', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'rating', type: 'integer', meta: { interface: 'slider' } },
|
||||||
|
{ field: 'hardcover_id', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'finished_at', type: 'date', meta: { interface: 'datetime' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of fields) {
|
||||||
|
await api('fields/book_reviews', 'POST', f);
|
||||||
|
}
|
||||||
|
console.log(' ✅ Fields created.');
|
||||||
|
|
||||||
|
await api('collections', 'POST', { collection: 'book_reviews_translations', meta: { hidden: true } });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'book_reviews_id', type: 'integer' });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'languages_code', type: 'string' });
|
||||||
|
await api('fields/book_reviews_translations', 'POST', { field: 'review', type: 'text', meta: { interface: 'input-multiline' } });
|
||||||
|
|
||||||
|
await api('fields/book_reviews', 'POST', { field: 'translations', type: 'alias', meta: { interface: 'translations', special: ['translations'] } });
|
||||||
|
await api('relations', 'POST', {
|
||||||
|
collection: 'book_reviews_translations',
|
||||||
|
field: 'book_reviews_id',
|
||||||
|
related_collection: 'book_reviews',
|
||||||
|
meta: { one_field: 'translations' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n✨ Setup Complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setup().catch(console.error);
|
||||||
79
scripts/setup-snippets.js
Normal file
79
scripts/setup-snippets.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN;
|
||||||
|
|
||||||
|
async function setupSnippets() {
|
||||||
|
console.log('📦 Setting up Snippets collection...');
|
||||||
|
|
||||||
|
// 1. Create Collection
|
||||||
|
try {
|
||||||
|
await fetch(`${DIRECTUS_URL}/collections`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
collection: 'snippets',
|
||||||
|
meta: { icon: 'terminal', display_template: '{{title}}' },
|
||||||
|
schema: { name: 'snippets' }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (_e) {}
|
||||||
|
|
||||||
|
// 2. Add Fields
|
||||||
|
const fields = [
|
||||||
|
{ field: 'status', type: 'string', meta: { interface: 'select-dropdown' }, schema: { default_value: 'published' } },
|
||||||
|
{ field: 'title', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'category', type: 'string', meta: { interface: 'input' } },
|
||||||
|
{ field: 'code', type: 'text', meta: { interface: 'input-code' } },
|
||||||
|
{ field: 'description', type: 'text', meta: { interface: 'textarea' } },
|
||||||
|
{ field: 'language', type: 'string', meta: { interface: 'input' }, schema: { default_value: 'javascript' } },
|
||||||
|
{ field: 'featured', type: 'boolean', meta: { interface: 'boolean' }, schema: { default_value: false } }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of fields) {
|
||||||
|
try {
|
||||||
|
await fetch(`${DIRECTUS_URL}/fields/snippets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(f)
|
||||||
|
});
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add Example Data
|
||||||
|
const exampleSnippets = [
|
||||||
|
{
|
||||||
|
title: 'Traefik SSL Config',
|
||||||
|
category: 'Docker',
|
||||||
|
language: 'yaml',
|
||||||
|
featured: true,
|
||||||
|
description: "Meine Standard-Konfiguration für automatisches SSL via Let's Encrypt in Docker Swarm.",
|
||||||
|
code: "labels:\n - \"traefik.enable=true\"\n - \"traefik.http.routers.myapp.rule=Host(`example.com`)\"\n - \"traefik.http.routers.myapp.entrypoints=websecure\"\n - \"traefik.http.routers.myapp.tls.certresolver=myresolver\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Docker Cleanup Alias',
|
||||||
|
category: 'ZSH',
|
||||||
|
language: 'bash',
|
||||||
|
featured: true,
|
||||||
|
description: 'Ein einfacher Alias, um ungenutzte Docker-Container, Images und Volumes schnell zu entfernen.',
|
||||||
|
code: "alias dclean='docker system prune -af --volumes'"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const s of exampleSnippets) {
|
||||||
|
try {
|
||||||
|
await fetch(`${DIRECTUS_URL}/items/snippets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${DIRECTUS_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(s)
|
||||||
|
});
|
||||||
|
} catch (_e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Snippets setup complete!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSnippets();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user