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 { notFound } from "next/navigation";
|
||||
import ConsentBanner from "../components/ConsentBanner";
|
||||
import { getLocalizedMessage } from "@/lib/i18n-loader";
|
||||
|
||||
// Supported locales - must match middleware.ts
|
||||
const SUPPORTED_LOCALES = ["en", "de"] as const;
|
||||
|
||||
@@ -3,6 +3,8 @@ import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
import { getProjectBySlug } from "@/lib/directus";
|
||||
import { ProjectDetailData } from "@/app/_ui/ProjectDetailClient";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
@@ -12,6 +14,20 @@ export async function generateMetadata({
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
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}` });
|
||||
return {
|
||||
alternates: {
|
||||
@@ -28,7 +44,8 @@ export default async function ProjectPage({
|
||||
}) {
|
||||
const { locale, slug } = await params;
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
// Try PostgreSQL first
|
||||
const dbProject = await prisma.project.findFirst({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
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));
|
||||
const trDefault = project.translations?.find(
|
||||
(t) => t.locale === project.defaultLocale && (t?.title || t?.description),
|
||||
);
|
||||
const tr = trPreferred ?? trDefault;
|
||||
const { translations: _translations, ...rest } = project;
|
||||
const localizedContent = (() => {
|
||||
if (typeof tr?.content === "string") return tr.content;
|
||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||
const markdown = (tr.content as Record<string, unknown>).markdown;
|
||||
if (typeof markdown === "string") return markdown;
|
||||
if (dbProject) {
|
||||
const trPreferred = dbProject.translations?.find((t) => t.locale === locale && (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 } = dbProject;
|
||||
const localizedContent = (() => {
|
||||
if (typeof tr?.content === "string") return tr.content;
|
||||
if (tr?.content && typeof tr.content === "object" && "markdown" in tr.content) {
|
||||
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 = {
|
||||
...rest,
|
||||
title: tr?.title ?? project.title,
|
||||
description: tr?.description ?? project.description,
|
||||
content: localizedContent,
|
||||
}
|
||||
|
||||
if (!projectData) return notFound();
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@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 ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
||||
import ProjectsPageClient, { ProjectListItem } from "@/app/_ui/ProjectsPageClient";
|
||||
import type { Metadata } from "next";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
import { getProjects as getDirectusProjects } from "@/lib/directus";
|
||||
|
||||
export const revalidate = 300;
|
||||
|
||||
@@ -27,7 +28,8 @@ export default async function ProjectsPage({
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
// Fetch from PostgreSQL
|
||||
const dbProjects = await prisma.project.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
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 trDefault = p.translations?.find(
|
||||
(t) => t.locale === p.defaultLocale && (t?.title || t?.description),
|
||||
);
|
||||
const tr = trPreferred ?? trDefault;
|
||||
const { translations: _translations, ...rest } = p;
|
||||
return {
|
||||
...rest,
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
title: tr?.title ?? p.title,
|
||||
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 || '')
|
||||
// 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(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 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', () => {
|
||||
it('renders the header', () => {
|
||||
it('renders the header with the dk logo', () => {
|
||||
render(<Header />);
|
||||
expect(screen.getByText('dk')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
|
||||
const aboutButtons = screen.getAllByText('About');
|
||||
expect(aboutButtons.length).toBeGreaterThan(0);
|
||||
|
||||
const projectsButtons = screen.getAllByText('Projects');
|
||||
expect(projectsButtons.length).toBeGreaterThan(0);
|
||||
|
||||
const contactButtons = screen.getAllByText('Contact');
|
||||
expect(contactButtons.length).toBeGreaterThan(0);
|
||||
// Check for navigation links
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
expect(screen.getByText('About')).toBeInTheDocument();
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contact')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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 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', () => {
|
||||
it('renders the hero section', () => {
|
||||
it('renders the hero section correctly', () => {
|
||||
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();
|
||||
|
||||
// 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 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', () => {
|
||||
it('renders the 404 page', () => {
|
||||
it('renders the 404 page with the new design text', () => {
|
||||
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 Footer from "../components/Footer";
|
||||
import Script from "next/script";
|
||||
import ActivityFeedClient from "./ActivityFeedClient";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function HomePage() {
|
||||
useEffect(() => {
|
||||
// Force scroll to top on mount to prevent starting at lower sections
|
||||
window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Script
|
||||
@@ -32,7 +37,6 @@ export default function HomePage() {
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<ActivityFeedClient />
|
||||
<Header />
|
||||
{/* Spacer to prevent navbar overlap */}
|
||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Header from "../components/Header.server";
|
||||
import Script from "next/script";
|
||||
import ActivityFeedClient from "./ActivityFeedClient";
|
||||
import {
|
||||
getHeroTranslations,
|
||||
getAboutTranslations,
|
||||
@@ -54,7 +53,6 @@ export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<ActivityFeedClient />
|
||||
<Header locale={locale} />
|
||||
{/* Spacer to prevent navbar overlap */}
|
||||
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { ExternalLink, ArrowLeft, Github as GithubIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export type ProjectDetailData = {
|
||||
id: number;
|
||||
@@ -16,10 +16,16 @@ export type ProjectDetailData = {
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
category: string;
|
||||
date: string;
|
||||
date?: string;
|
||||
created_at?: string;
|
||||
github?: string | null;
|
||||
github_url?: string | null;
|
||||
live?: string | null;
|
||||
button_live_label?: string | null;
|
||||
button_github_label?: string | null;
|
||||
imageUrl?: string | null;
|
||||
image_url?: string | null;
|
||||
technologies?: string[];
|
||||
};
|
||||
|
||||
export default function ProjectDetailClient({
|
||||
@@ -31,213 +37,140 @@ export default function ProjectDetailClient({
|
||||
}) {
|
||||
const tCommon = useTranslations("common");
|
||||
const tDetail = useTranslations("projects.detail");
|
||||
const tShared = useTranslations("projects.shared");
|
||||
const router = useRouter();
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
|
||||
// Track page view (non-blocking)
|
||||
useEffect(() => {
|
||||
// Prüfen, ob wir eine History haben (von Home gekommen)
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
setCanGoBack(true);
|
||||
}
|
||||
|
||||
try {
|
||||
navigator.sendBeacon?.(
|
||||
"/api/analytics/track",
|
||||
new Blob(
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "pageview",
|
||||
projectId: project.id.toString(),
|
||||
page: `/${locale}/projects/${project.slug}`,
|
||||
}),
|
||||
],
|
||||
{ type: "application/json" },
|
||||
),
|
||||
new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
}, [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 (
|
||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
{/* Navigation */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8"
|
||||
<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">
|
||||
|
||||
{/* Navigation - Intelligent Back */}
|
||||
<button
|
||||
onClick={handleBack}
|
||||
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"
|
||||
>
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-900 transition-colors group"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-medium">{tCommon("backToProjects")}</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-bold uppercase tracking-widest text-xs">
|
||||
{tCommon("back")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Header & Meta */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.1 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<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">
|
||||
{/* Title Section */}
|
||||
<div className="mb-20">
|
||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase mb-8">
|
||||
{project.title}<span className="text-liquid-mint">.</span>
|
||||
</h1>
|
||||
<p className="text-xl md:text-3xl font-light text-stone-500 dark:text-stone-400 max-w-4xl leading-snug tracking-tight">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-6 text-stone-500 text-sm border-y border-stone-200 py-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar size={18} />
|
||||
<span className="font-mono">
|
||||
{new Date(project.date).toLocaleDateString(locale || undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-stone-300 hidden sm:block"></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<span key={tag} className="text-stone-700 font-medium">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{/* Feature Image Box */}
|
||||
<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">
|
||||
<div className="relative aspect-video rounded-[2rem] overflow-hidden border-4 border-stone-50 dark:border-stone-800 shadow-2xl">
|
||||
{project.imageUrl ? (
|
||||
<Image src={project.imageUrl} alt={project.title} fill className="object-cover" priority sizes="100vw" />
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-stone-100 dark:bg-stone-800 flex items-center justify-center">
|
||||
<span className="text-[15rem] font-black text-stone-200 dark:text-stone-700">{project.title.charAt(0)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
<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">
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
{/* Featured Image / Fallback */}
|
||||
<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>
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
|
||||
{/* 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 */}
|
||||
<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>
|
||||
)}
|
||||
{/* Quick Links Box - Only show if links exist */}
|
||||
|
||||
{project.github && project.github.trim() && 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>
|
||||
{((project.live && project.live !== "#") || (project.github && project.github !== "#")) && (
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-stone-100">
|
||||
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">{tDetail("techStack")}</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2.5 py-1 bg-stone-100 text-stone-600 text-xs font-medium rounded-md border border-stone-200"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
|
||||
<h3 className="text-xl font-black mb-8 uppercase tracking-widest text-liquid-mint">Links</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
{project.live && project.live !== "#" && (
|
||||
|
||||
<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>{project.button_live_label || tDetail("liveDemo")}</span>
|
||||
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,21 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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 { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { Skeleton } from "../components/ui/Skeleton";
|
||||
|
||||
export type ProjectListItem = {
|
||||
id: number;
|
||||
id: number | string; // Allow both for Directus (string) and Prisma (number) compatibility
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
category: string;
|
||||
date: string;
|
||||
github?: string | null;
|
||||
live?: string | null;
|
||||
date?: string;
|
||||
createdAt?: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
@@ -30,14 +29,15 @@ export default function ProjectsPageClient({
|
||||
}) {
|
||||
const tCommon = useTranslations("common");
|
||||
const tList = useTranslations("projects.list");
|
||||
const tShared = useTranslations("projects.shared");
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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(() => {
|
||||
@@ -47,248 +47,111 @@ export default function ProjectsPageClient({
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
let result = projects;
|
||||
|
||||
if (selectedCategory !== "all") {
|
||||
result = result.filter((project) => project.category === selectedCategory);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(project) =>
|
||||
project.title.toLowerCase().includes(query) ||
|
||||
project.description.toLowerCase().includes(query) ||
|
||||
project.tags.some((tag) => tag.toLowerCase().includes(query)),
|
||||
(p) => p.title.toLowerCase().includes(query) || p.description.toLowerCase().includes(query) || p.tags.some(t => t.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [projects, selectedCategory, searchQuery]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fdfcf8] pt-32 pb-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<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">
|
||||
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<div className="mb-24">
|
||||
<Link
|
||||
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" />
|
||||
<span>{tCommon("backToHome")}</span>
|
||||
<span className="font-bold uppercase tracking-widest text-xs">{tCommon("backToHome")}</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
||||
{tList("title")}
|
||||
|
||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||
Archive<span className="text-liquid-mint">.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
|
||||
</motion.div>
|
||||
<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">
|
||||
{tList("intro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters & Search */}
|
||||
<motion.div
|
||||
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 */}
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-8 justify-between items-start md:items-center mb-16">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-5 py-2 rounded-full text-sm font-medium transition-all duration-200 border ${
|
||||
selectedCategory === category
|
||||
? "bg-stone-800 text-stone-50 border-stone-800 shadow-md"
|
||||
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
|
||||
key={cat}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
className={`px-6 py-2 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${
|
||||
selectedCategory === cat
|
||||
? "bg-stone-900 dark:bg-stone-100 text-white dark:text-stone-900"
|
||||
: "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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={tList("searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
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>
|
||||
</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>
|
||||
|
||||
{filteredProjects.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-stone-500 text-lg">{tList("noResults")}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory("all");
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className="mt-4 text-stone-800 font-medium hover:underline"
|
||||
>
|
||||
{tList("clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{loading ? (
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<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">
|
||||
<Skeleton className="aspect-[16/10] rounded-[2rem] mb-8" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 { getHobbies } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
|
||||
* - 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';
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
// 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)
|
||||
const result: Record<string, string> = {};
|
||||
@@ -57,19 +57,24 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Helper: Holt verschachtelte Werte aus Objekt
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
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
|
||||
function flattenObject(obj: any, prefix = ''): Record<string, string> {
|
||||
function flattenObject(obj: Record<string, unknown>, prefix = ''): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const newKey = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
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 {
|
||||
result[newKey] = String(value);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import deMessages from '@/messages/de.json';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getMessages } from "@/lib/directus";
|
||||
|
||||
// Cache für 5 Minuten
|
||||
export const revalidate = 300;
|
||||
|
||||
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';
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const locale = searchParams.get("locale") || "en";
|
||||
|
||||
try {
|
||||
// Starte mit JSON als Basis
|
||||
const jsonMessages = messagesMap[normalizedLocale as 'en' | 'de'];
|
||||
|
||||
// Clone das Objekt
|
||||
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',
|
||||
},
|
||||
});
|
||||
const messages = await getMessages(locale);
|
||||
return NextResponse.json({ messages });
|
||||
} catch {
|
||||
return NextResponse.json({ messages: {} }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 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 { generateUniqueSlug } from '@/lib/slug';
|
||||
import { getProjects as getDirectusProjects } from '@/lib/directus';
|
||||
import { ProjectListItem } from '@/app/_ui/ProjectsPageClient';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -41,87 +42,80 @@ export async function GET(request: NextRequest) {
|
||||
const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 200 ? limitRaw : 50;
|
||||
const category = searchParams.get('category');
|
||||
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 search = searchParams.get('search');
|
||||
const locale = searchParams.get('locale') || 'en';
|
||||
|
||||
// Try Directus FIRST (Primary Source)
|
||||
let directusProjects: ProjectListItem[] = [];
|
||||
let directusSuccess = false;
|
||||
try {
|
||||
const directusProjects = await getDirectusProjects(locale, {
|
||||
const fetched = await getDirectusProjects(locale, {
|
||||
featured: featured === 'true' ? true : featured === 'false' ? false : undefined,
|
||||
published: published === 'true' ? true : published === 'false' ? false : undefined,
|
||||
published: published,
|
||||
category: category || undefined,
|
||||
difficulty: difficulty || undefined,
|
||||
search: search || undefined,
|
||||
limit
|
||||
});
|
||||
|
||||
if (directusProjects && directusProjects.length > 0) {
|
||||
return NextResponse.json({
|
||||
projects: directusProjects,
|
||||
total: directusProjects.length,
|
||||
page: 1,
|
||||
limit: directusProjects.length,
|
||||
source: 'directus'
|
||||
});
|
||||
if (fetched) {
|
||||
directusProjects = fetched.map(p => ({
|
||||
id: typeof p.id === 'string' ? (parseInt(p.id) || 0) : p.id,
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
tags: p.tags || [],
|
||||
category: p.category || '',
|
||||
date: p.created_at,
|
||||
createdAt: p.created_at,
|
||||
imageUrl: p.image_url,
|
||||
}));
|
||||
directusSuccess = true;
|
||||
}
|
||||
} catch (directusError) {
|
||||
console.log('Directus not available, trying PostgreSQL fallback');
|
||||
} catch {
|
||||
console.log('Directus error, continuing with PostgreSQL fallback');
|
||||
}
|
||||
|
||||
// Fallback 1: Try PostgreSQL
|
||||
try {
|
||||
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)
|
||||
// If Directus returned projects, use them EXCLUSIVELY to avoid showing un-synced local data
|
||||
if (directusSuccess && directusProjects.length > 0) {
|
||||
return NextResponse.json({
|
||||
projects: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
source: 'fallback'
|
||||
projects: directusProjects,
|
||||
total: directusProjects.length,
|
||||
source: 'directus'
|
||||
});
|
||||
}
|
||||
|
||||
// Create cache parameters object
|
||||
const cacheParams = {
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
category,
|
||||
featured,
|
||||
published,
|
||||
difficulty,
|
||||
search
|
||||
};
|
||||
|
||||
// Check cache first
|
||||
const cached = await apiCache.getProjects(cacheParams);
|
||||
if (cached && !search) { // Don't cache search results
|
||||
return NextResponse.json(cached);
|
||||
// Fallback 1: Try PostgreSQL only if Directus failed or is empty
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
console.log('PostgreSQL not available');
|
||||
return NextResponse.json({
|
||||
projects: directusProjects, // Might be empty
|
||||
total: directusProjects.length,
|
||||
source: 'directus-empty'
|
||||
});
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (category) where.category = category;
|
||||
if (featured !== null) where.featured = featured === 'true';
|
||||
if (published !== null) where.published = published === 'true';
|
||||
where.published = published;
|
||||
if (difficulty) where.difficulty = difficulty;
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
{ tags: { hasSome: [search] } },
|
||||
{ content: { contains: search, mode: 'insensitive' } }
|
||||
{ tags: { hasSome: [search] } }
|
||||
];
|
||||
}
|
||||
|
||||
const [projects, total] = await Promise.all([
|
||||
const [dbProjects, total] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -131,20 +125,31 @@ export async function GET(request: NextRequest) {
|
||||
prisma.project.count({ where })
|
||||
]);
|
||||
|
||||
const result = {
|
||||
projects,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
currentPage: page,
|
||||
source: 'postgresql'
|
||||
};
|
||||
|
||||
// Cache the result (only for non-search queries)
|
||||
if (!search) {
|
||||
await apiCache.setProjects(cacheParams, result);
|
||||
// Merge logic
|
||||
const dbSlugs = new Set(dbProjects.map(p => p.slug));
|
||||
const mergedProjects: ProjectListItem[] = dbProjects.map(p => ({
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
tags: p.tags,
|
||||
category: p.category,
|
||||
date: p.date,
|
||||
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) {
|
||||
// Handle missing database table gracefully
|
||||
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 { getTechStack } from '@/lib/directus';
|
||||
import { checkRateLimit, getClientIp } from '@/lib/auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -13,6 +14,12 @@ export const dynamic = 'force-dynamic';
|
||||
* - 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';
|
||||
|
||||
@@ -1,397 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Globe, Server, Wrench, Shield, Gamepad2, Code, Activity, Lightbulb, BookOpen, MessageSquare, ArrowRight, Tv, Plane, Camera, Stars, Music, Terminal, Cpu } from "lucide-react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
import RichTextClient from "./RichTextClient";
|
||||
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
|
||||
interface TechStackItem {
|
||||
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 iconMap: Record<string, LucideIcon> = {
|
||||
Globe, Server, Code, Wrench, Shield, Activity, Lightbulb, Gamepad2, BookOpen, Tv, Plane, Camera, Stars, Music, Terminal, Cpu
|
||||
};
|
||||
|
||||
const About = () => {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("home.about");
|
||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||
const [techStackFromCMS, setTechStackFromCMS] = useState<TechStackCategory[] | null>(null);
|
||||
const [hobbiesFromCMS, setHobbiesFromCMS] = useState<Hobby[] | null>(null);
|
||||
const [techStack, setTechStack] = useState<TechStackCategory[]>([]);
|
||||
const [hobbies, setHobbies] = useState<Hobby[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/content/page?key=${encodeURIComponent("home-about")}&locale=${encodeURIComponent(locale)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
// Only use CMS content if it exists for the active locale.
|
||||
if (data?.content?.content && data?.content?.locale === locale) {
|
||||
setCmsDoc(data.content.content as JSONContent);
|
||||
} else {
|
||||
setCmsDoc(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore; fallback to static
|
||||
setCmsDoc(null);
|
||||
}
|
||||
})();
|
||||
}, [locale]);
|
||||
const [cmsRes, techRes, hobbiesRes, msgRes, booksRes, snippetsRes] = await Promise.all([
|
||||
fetch(`/api/content/page?key=home-about&locale=${locale}`),
|
||||
fetch(`/api/tech-stack?locale=${locale}`),
|
||||
fetch(`/api/hobbies?locale=${locale}`),
|
||||
fetch(`/api/messages?locale=${locale}`),
|
||||
fetch(`/api/book-reviews?locale=${locale}`),
|
||||
fetch(`/api/snippets?limit=3&featured=true`)
|
||||
]);
|
||||
|
||||
const cmsData = await cmsRes.json();
|
||||
if (cmsData?.content?.content) setCmsDoc(cmsData.content.content as JSONContent);
|
||||
|
||||
const techData = await techRes.json();
|
||||
if (techData?.techStack) setTechStack(techData.techStack);
|
||||
|
||||
const hobbiesData = await hobbiesRes.json();
|
||||
if (hobbiesData?.hobbies) setHobbies(hobbiesData.hobbies);
|
||||
|
||||
// Load Tech Stack from Directus
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/tech-stack?locale=${encodeURIComponent(locale)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data?.techStack && data.techStack.length > 0) {
|
||||
setTechStackFromCMS(data.techStack);
|
||||
}
|
||||
}
|
||||
const msgData = await msgRes.json();
|
||||
if (msgData?.messages) setCmsMessages(msgData.messages);
|
||||
|
||||
const snippetsData = await snippetsRes.json();
|
||||
if (snippetsData?.snippets) setSnippets(snippetsData.snippets);
|
||||
|
||||
await booksRes.json();
|
||||
// Books data is available but we don't need to track count anymore
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Tech Stack from Directus not available, using fallback');
|
||||
}
|
||||
console.error("About data fetch failed:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
fetchData();
|
||||
}, [locale]);
|
||||
|
||||
// Load Hobbies from Directus
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
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
|
||||
const copyToClipboard = (code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<section
|
||||
id="about"
|
||||
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="max-w-6xl mx-auto relative z-10">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
|
||||
{/* Text Content */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={staggerContainer}
|
||||
className="space-y-8"
|
||||
<section id="about" className="py-32 px-6 bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
|
||||
|
||||
{/* 1. Large Bio Text */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="md:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<motion.h2
|
||||
variants={fadeInUp}
|
||||
className="text-4xl md:text-5xl font-bold text-stone-900"
|
||||
>
|
||||
{t("title")}
|
||||
</motion.h2>
|
||||
<motion.div
|
||||
variants={fadeInUp}
|
||||
className="prose prose-stone prose-lg text-stone-700 space-y-4"
|
||||
>
|
||||
{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 className="space-y-8">
|
||||
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase">
|
||||
{t("title")}<span className="text-liquid-mint">.</span>
|
||||
</h2>
|
||||
<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">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-[95%]" />
|
||||
<Skeleton className="h-6 w-[90%]" />
|
||||
</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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Tech Stack & Hobbies */}
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={staggerContainer}
|
||||
className="space-y-8"
|
||||
{/* 2. Activity / Status Box */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="md:col-span-4 bg-stone-900 rounded-[3rem] p-10 border border-stone-800 shadow-2xl text-white overflow-hidden relative flex flex-col"
|
||||
>
|
||||
<div>
|
||||
<motion.h3
|
||||
variants={fadeInUp}
|
||||
className="text-2xl font-bold text-stone-900 mb-6"
|
||||
>
|
||||
{t("techStackTitle")}
|
||||
</motion.h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{techStack.map((stack, idx) => (
|
||||
<motion.div
|
||||
key={`${stack.category}-${idx}`}
|
||||
variants={fadeInUp}
|
||||
whileHover={{
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
}}
|
||||
className={`p-5 rounded-xl border-2 transition-[background-color,border-color,box-shadow] duration-500 ease-out backdrop-blur-md ${
|
||||
idx === 0
|
||||
? "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"
|
||||
: idx === 1
|
||||
? "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"
|
||||
: idx === 2
|
||||
? "bg-gradient-to-br from-liquid-lavender/25 to-liquid-pink/25 border-liquid-lavender/50 hover:border-liquid-lavender/70 hover:from-liquid-lavender/35 hover:to-liquid-pink/35"
|
||||
: "bg-gradient-to-br from-liquid-teal/25 to-liquid-lime/25 border-liquid-teal/50 hover:border-liquid-teal/70 hover:from-liquid-teal/35 hover:to-liquid-lime/35"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm text-stone-700">
|
||||
<stack.icon size={18} />
|
||||
</div>
|
||||
<h4 className="font-semibold text-stone-800">
|
||||
{stack.category}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="relative z-10 h-full">
|
||||
<h3 className="text-xl font-black mb-10 flex items-center gap-2 uppercase tracking-widest text-liquid-mint">
|
||||
<Activity size={20} /> Status
|
||||
</h3>
|
||||
<ActivityFeed idleQuote={cmsMessages["about.quote.idle"]} locale={locale} />
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-liquid-mint/10 blur-[100px] rounded-full" />
|
||||
</motion.div>
|
||||
|
||||
{/* 3. AI Chat Box */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="md:col-span-12 lg:col-span-4 bg-stone-50 dark:bg-stone-900 rounded-[3rem] p-10 border border-stone-200/60 dark:border-stone-800/60 flex flex-col shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
<MessageSquare className="text-liquid-purple" size={24} />
|
||||
<h3 className="text-2xl font-black text-stone-900 dark:text-stone-50 uppercase tracking-tighter text-liquid-purple">AI Assistant</h3>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<BentoChat />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 4. Tech Stack */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="md:col-span-12 lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<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">
|
||||
{stack.items.map((item, itemIdx) => (
|
||||
<span
|
||||
key={`${stack.category}-${item}-${itemIdx}`}
|
||||
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 ${
|
||||
itemIdx % 4 === 0
|
||||
? "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"
|
||||
: itemIdx % 4 === 2
|
||||
? "bg-liquid-rose/25 border-liquid-rose/50 hover:bg-liquid-rose/35 hover:border-liquid-rose/70"
|
||||
: "bg-liquid-sky/25 border-liquid-sky/50 hover:bg-liquid-sky/35 hover:border-liquid-sky/70"
|
||||
}`}
|
||||
>
|
||||
{String(item)}
|
||||
<Skeleton className="h-8 w-24 rounded-xl" />
|
||||
<Skeleton className="h-8 w-16 rounded-xl" />
|
||||
<Skeleton className="h-8 w-20 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
techStack.map((cat) => (
|
||||
<div key={cat.id} className="space-y-6">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">{cat.name}</h4>
|
||||
<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">
|
||||
{item.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
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 { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||
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 })), {
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
const ChatWidget = dynamic(() => import("./ChatWidget").catch(() => ({ default: () => null })), {
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
});
|
||||
|
||||
export default function ClientProviders({
|
||||
children,
|
||||
}: {
|
||||
@@ -72,9 +68,21 @@ export default function ClientProviders({
|
||||
<ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
<ConsentProvider>
|
||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
||||
{children}
|
||||
</GatedProviders>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<GatedProviders mounted={mounted} is404Page={is404Page}>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</GatedProviders>
|
||||
</ThemeProvider>
|
||||
</ConsentProvider>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
@@ -84,27 +92,21 @@ export default function ClientProviders({
|
||||
function GatedProviders({
|
||||
children,
|
||||
mounted,
|
||||
is404Page,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
mounted: boolean;
|
||||
is404Page: boolean;
|
||||
}) {
|
||||
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
|
||||
const analyticsEnabled = !!consent?.analytics;
|
||||
const chatEnabled = !!consent?.chat;
|
||||
|
||||
const content = (
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
{mounted && <BackgroundBlobs />}
|
||||
<div className="relative z-10">{children}</div>
|
||||
{mounted && !is404Page && !isAdminRoute && chatEnabled && <ChatWidget />}
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ function getNormalizedLocale(locale: string): 'en' | 'de' {
|
||||
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 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 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 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 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 baseMessages = messageMap[normalLocale];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
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 { useLocale, useTranslations } from "next-intl";
|
||||
import type { JSONContent } from "@tiptap/react";
|
||||
@@ -152,118 +152,120 @@ const Contact = () => {
|
||||
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 (
|
||||
<section
|
||||
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">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6 text-stone-900">
|
||||
{t("title")}
|
||||
</h2>
|
||||
{cmsDoc ? (
|
||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-2xl mx-auto mt-4 text-stone-700" />
|
||||
) : (
|
||||
<p className="text-xl text-stone-700 max-w-2xl mx-auto mt-4">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8">
|
||||
|
||||
{/* Header Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="space-y-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="md:col-span-12 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-stone-900 mb-6">
|
||||
{t("getInTouch")}
|
||||
</h3>
|
||||
<p className="text-stone-700 leading-relaxed">
|
||||
{t("getInTouchBody")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<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 className="max-w-3xl">
|
||||
<h2 className="text-4xl md:text-7xl font-black text-stone-900 dark:text-stone-50 tracking-tighter uppercase mb-8">
|
||||
{t("title")}<span className="text-liquid-mint">.</span>
|
||||
</h2>
|
||||
{cmsDoc ? (
|
||||
<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 className="text-xl md:text-2xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Contact Form */}
|
||||
{/* Info Side (Unified Connect Box) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="glass-card p-8 rounded-3xl bg-white/50 border border-white/70"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="md:col-span-12 lg:col-span-4 flex flex-col gap-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")}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Name <span className="text-liquid-rose">*</span>
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
{tForm("labels.name")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -273,32 +275,14 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
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 ${
|
||||
errors.name && touched.name
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
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"
|
||||
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>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Email <span className="text-liquid-rose">*</span>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
{tForm("labels.email")}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -308,33 +292,15 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
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 ${
|
||||
errors.email && touched.email
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
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"
|
||||
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>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Subject <span className="text-liquid-rose">*</span>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="subject" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
{tForm("labels.subject")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -344,34 +310,14 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
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 ${
|
||||
errors.subject && touched.subject
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
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"
|
||||
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>
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-stone-600 mb-2"
|
||||
>
|
||||
Message <span className="text-liquid-rose">*</span>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="message" className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400 pl-4">
|
||||
{tForm("labels.message")}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
@@ -380,53 +326,25 @@ const Contact = () => {
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
required
|
||||
rows={6}
|
||||
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 ${
|
||||
errors.message && touched.message
|
||||
? "border-red-400 focus:ring-red-400"
|
||||
: "border-white/60 focus:ring-liquid-blue focus:border-transparent"
|
||||
}`}
|
||||
rows={5}
|
||||
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"
|
||||
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>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
whileHover={!isSubmitting ? { scale: 1.02, y: -2 } : {}}
|
||||
whileTap={!isSubmitting ? { scale: 0.98 } : {}}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
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"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>{tForm("sending")}</span>
|
||||
</>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send size={20} />
|
||||
<span className="text-cream">{tForm("send")}</span>
|
||||
<Send size={16} />
|
||||
{tForm("send")}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { motion } from "framer-motion";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import { Skeleton } from "./ui/Skeleton";
|
||||
|
||||
interface CurrentlyReading {
|
||||
title: string;
|
||||
@@ -53,8 +55,26 @@ const CurrentlyReading = () => {
|
||||
fetchCurrentlyReading();
|
||||
}, []); // 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
|
||||
if (loading || books.length === 0) {
|
||||
if (books.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -62,8 +82,8 @@ const CurrentlyReading = () => {
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BookOpen size={18} className="text-stone-600 flex-shrink-0" />
|
||||
<h3 className="text-lg font-bold text-stone-900">
|
||||
<BookOpen 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")} {books.length > 1 && `(${books.length})`}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -80,11 +100,11 @@ const CurrentlyReading = () => {
|
||||
scale: 1.02,
|
||||
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 */}
|
||||
<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={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.3, 0.5, 0.3],
|
||||
@@ -106,12 +126,13 @@ const CurrentlyReading = () => {
|
||||
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
||||
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">
|
||||
<img
|
||||
<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">
|
||||
<Image
|
||||
src={book.image}
|
||||
alt={book.title}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 96px, 112px"
|
||||
/>
|
||||
{/* Glossy Overlay */}
|
||||
<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 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 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}
|
||||
</h4>
|
||||
|
||||
{/* 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(", ")}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<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 className="font-semibold">{book.progress}%</span>
|
||||
<span className="font-semibold">{isNaN(book.progress) ? 0 : book.progress}%</span>
|
||||
</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
|
||||
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" }}
|
||||
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";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Heart, Code } from 'lucide-react';
|
||||
import { SiGithub, SiLinkedin } from 'react-icons/si';
|
||||
import Link from 'next/link';
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useConsent } from "./ConsentProvider";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
|
||||
const Footer = () => {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("footer");
|
||||
const { resetConsent } = useConsent();
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const [currentYear] = useState(() => new Date().getFullYear());
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: SiGithub, href: 'https://github.com/Denshooter', label: 'GitHub' },
|
||||
{ icon: SiLinkedin, href: 'https://linkedin.com/in/dkonkol', label: 'LinkedIn' }
|
||||
];
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
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="flex flex-col md:flex-row justify-between items-center space-y-6 md:space-y-0">
|
||||
{/* Brand */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
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 className="grid grid-cols-1 md:grid-cols-12 gap-12 items-end">
|
||||
{/* Copyright & Info */}
|
||||
<div className="md:col-span-4 space-y-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 font-black text-xs">
|
||||
dk
|
||||
</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 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.4, delay: 0.05 }}
|
||||
className="flex space-x-3"
|
||||
>
|
||||
{socialLinks.map((social) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.15, y: -3 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
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"
|
||||
aria-label={social.label}
|
||||
>
|
||||
<social.icon size={18} />
|
||||
</motion.a>
|
||||
))}
|
||||
</motion.div>
|
||||
{/* Navigation Links */}
|
||||
<div className="md:col-span-4 grid grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Legal</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link href={`/${locale}/legal-notice`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("legalNotice")}</Link>
|
||||
<Link href={`/${locale}/privacy-policy`} className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">{t("privacyPolicy")}</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-black uppercase tracking-[0.2em] text-stone-400">Social</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a href="https://github.com/Denshooter" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">GitHub</a>
|
||||
<a href="https://linkedin.com/in/dkonkol" target="_blank" rel="noopener noreferrer" className="text-sm font-bold text-stone-600 dark:text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors">LinkedIn</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
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 }}
|
||||
{/* Back to Top */}
|
||||
<div className="md:col-span-4 flex justify-start md:justify-end">
|
||||
<button
|
||||
onClick={scrollToTop}
|
||||
className="group flex flex-col items-center gap-4 text-stone-400 hover:text-stone-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<Heart size={14} className="text-liquid-rose fill-liquid-rose" />
|
||||
</motion.div>
|
||||
<span>{t("madeIn")}</span>
|
||||
</motion.div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] vertical-text transform rotate-180" style={{ writingMode: 'vertical-rl' }}>Back to top</span>
|
||||
<div className="w-12 h-12 rounded-full border border-stone-200 dark:border-stone-800 flex items-center justify-center group-hover:bg-stone-900 dark:group-hover:bg-stone-50 group-hover:text-white dark:group-hover:text-stone-900 transition-all shadow-sm">
|
||||
<ArrowUp size={20} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legal Links */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
transition={{ duration: 0.4, delay: 0.15 }}
|
||||
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="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>
|
||||
{/* Bottom Bar */}
|
||||
<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">
|
||||
<p className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">
|
||||
Built with Next.js, Directus & Passion.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
<span className="text-[10px] font-bold text-stone-400 uppercase tracking-widest">Systems Online</span>
|
||||
</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>
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Menu, X, Mail } from "lucide-react";
|
||||
import { SiGithub, SiLinkedin } from "react-icons/si";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
|
||||
const Header = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const t = useTranslations("nav");
|
||||
|
||||
const isHome = pathname === `/${locale}` || pathname === `/${locale}/`;
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY > 50);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ name: t("home"), href: `/${locale}` },
|
||||
{ name: t("about"), href: isHome ? "#about" : `/${locale}#about` },
|
||||
@@ -34,232 +23,82 @@ const Header = () => {
|
||||
{ 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 (
|
||||
<>
|
||||
<motion.header
|
||||
initial={false}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="fixed top-6 left-0 right-0 z-50 flex justify-center px-4 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className={`pointer-events-auto transition-all duration-500 ease-out ${
|
||||
scrolled ? "w-full max-w-5xl" : "w-full max-w-7xl"
|
||||
}`}
|
||||
<div className="fixed top-8 left-0 right-0 z-50 flex justify-center px-6 pointer-events-none">
|
||||
<motion.nav
|
||||
initial={{ y: -100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="pointer-events-auto bg-white/70 dark:bg-stone-900/70 backdrop-blur-2xl border border-white/40 dark:border-white/5 shadow-[0_8px_32px_rgba(0,0,0,0.05)] rounded-full px-3 py-2 flex items-center gap-1 md:gap-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className={`
|
||||
backdrop-blur-xl transition-all duration-500
|
||||
${
|
||||
scrolled
|
||||
? "bg-white/95 border border-stone-200/50 shadow-[0_8px_30px_rgba(0,0,0,0.12)] rounded-full px-6 py-3"
|
||||
: "bg-white/85 border border-stone-200/30 shadow-[0_4px_24px_rgba(0,0,0,0.08)] px-4 py-4 rounded-full"
|
||||
}
|
||||
flex justify-between items-center
|
||||
`}
|
||||
{/* Logo Pill */}
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-stone-900 dark:bg-stone-50 text-white dark:text-stone-900 transition-transform hover:scale-105 active:scale-95 shadow-lg"
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<span className="font-black text-xs tracking-tighter">dk</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
href={`/${locale}`}
|
||||
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
|
||||
key={item.name}
|
||||
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>
|
||||
</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) => (
|
||||
<motion.div
|
||||
<Link
|
||||
key={item.name}
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
href={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-6 py-4 text-sm font-black uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100 bg-stone-50 dark:bg-white/5 rounded-2xl transition-colors hover:bg-liquid-mint/10"
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-stone-600 hover:text-stone-900 transition-colors duration-200 font-medium relative group px-2 py-1 liquid-hover"
|
||||
onClick={(e) => {
|
||||
if (item.href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
<motion.span
|
||||
className="absolute -bottom-1 left-0 right-0 h-0.5 bg-gradient-to-r from-liquid-mint to-liquid-lavender rounded-full"
|
||||
initial={{ scaleX: 0, opacity: 0 }}
|
||||
whileHover={{ scaleX: 1, opacity: 1 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
}}
|
||||
style={{ transformOrigin: "left center" }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-3">
|
||||
<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
|
||||
{item.name}
|
||||
</Link>
|
||||
<Link
|
||||
href={deHref}
|
||||
className={`px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
locale === "de"
|
||||
? "bg-stone-900 text-stone-50"
|
||||
: "text-stone-700 hover:bg-white/60"
|
||||
}`}
|
||||
aria-label="Sprache auf Deutsch umstellen"
|
||||
>
|
||||
DE
|
||||
</Link>
|
||||
</div>
|
||||
{socialLinks.map((social) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-2 rounded-full bg-white/40 hover:bg-white/80 border border-white/50 text-stone-600 hover:text-stone-900 transition-all shadow-sm liquid-hover"
|
||||
>
|
||||
<social.icon size={18} />
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden p-2 rounded-full bg-white/40 hover:bg-white/60 text-stone-800 transition-colors liquid-hover"
|
||||
aria-label={isOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-40 md:hidden pointer-events-auto"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
transition={{ duration: 0.3, type: "spring" }}
|
||||
className="absolute top-24 left-4 right-4 bg-cream/95 backdrop-blur-xl border border-stone-200 shadow-xl rounded-3xl z-50 p-6 pointer-events-auto"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.name}
|
||||
initial={{ x: -20, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -20, opacity: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
setIsOpen(false);
|
||||
if (item.href.startsWith("#")) {
|
||||
e.preventDefault();
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(item.href);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
className="block text-stone-600 hover:text-stone-900 hover:bg-white/50 transition-all font-medium py-3 px-4 rounded-xl"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<div className="pt-6 mt-4 border-t border-stone-200">
|
||||
<div className="flex justify-center space-x-4">
|
||||
{socialLinks.map((social, index) => (
|
||||
<motion.a
|
||||
key={social.label}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{
|
||||
delay: (navItems.length + index) * 0.05,
|
||||
}}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
className="p-3 rounded-full bg-white/60 text-stone-600 shadow-sm"
|
||||
aria-label={social.label}
|
||||
>
|
||||
<social.icon size={20} />
|
||||
</motion.a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.header>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,251 +1,138 @@
|
||||
"use client";
|
||||
|
||||
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 type { JSONContent } from "@tiptap/react";
|
||||
import RichTextClient from "./RichTextClient";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const Hero = () => {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("home.hero");
|
||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||
const [cmsMessages, setCmsMessages] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/content/page?key=${encodeURIComponent("home-hero")}&locale=${encodeURIComponent(locale)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
// Only use CMS content if it exists for the active locale.
|
||||
// If the API falls back to another locale, keep showing next-intl strings
|
||||
// so the locale switch visibly changes the page.
|
||||
if (data?.content?.content && data?.content?.locale === locale) {
|
||||
setCmsDoc(data.content.content as JSONContent);
|
||||
} else {
|
||||
setCmsDoc(null);
|
||||
const res = await fetch(`/api/messages?locale=${locale}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCmsMessages(data.messages || {});
|
||||
}
|
||||
} catch {
|
||||
// ignore; fallback to static
|
||||
setCmsDoc(null);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, [locale]);
|
||||
|
||||
const features = [
|
||||
{ icon: Code, text: t("features.f1") },
|
||||
{ icon: Zap, text: t("features.f2") },
|
||||
{ icon: Rocket, text: t("features.f3") },
|
||||
];
|
||||
// Helper to get CMS text or fallback
|
||||
const getLabel = (key: string, fallback: string) => cmsMessages[key] || fallback;
|
||||
|
||||
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">
|
||||
<div className="relative z-10 text-center px-4 max-w-5xl mx-auto">
|
||||
{/* Profile Image with Organic Blob Mask */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="mb-12 flex justify-center relative z-20"
|
||||
>
|
||||
<div className="relative w-64 h-64 md:w-80 md:h-80 flex items-center justify-center">
|
||||
{/* Large Rotating Liquid Blobs behind image - Very slow and smooth */}
|
||||
<motion.div
|
||||
className="absolute w-[150%] h-[150%] bg-gradient-to-tr from-liquid-mint/40 via-liquid-blue/30 to-liquid-lavender/40 blur-3xl -z-10"
|
||||
animate={{
|
||||
borderRadius: [
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
],
|
||||
rotate: [0, 120, 0],
|
||||
scale: [1, 1.08, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 35,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute w-[130%] h-[130%] bg-gradient-to-bl from-liquid-rose/35 via-purple-200/25 to-liquid-mint/35 blur-2xl -z-10"
|
||||
animate={{
|
||||
borderRadius: [
|
||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
||||
"60% 30% 40% 70%/60% 40% 70% 30%",
|
||||
"40% 60% 70% 30%/40% 50% 60% 50%",
|
||||
],
|
||||
rotate: [0, -90, 0],
|
||||
scale: [1, 1.05, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 40,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
/>
|
||||
<section className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden bg-stone-50 dark:bg-stone-950 px-6 transition-colors duration-500">
|
||||
{/* Liquid Ambient Background */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1], opacity: [0.15, 0.25, 0.15] }}
|
||||
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute top-[5%] left-[5%] w-[60vw] h-[60vw] bg-liquid-mint rounded-full blur-[140px]"
|
||||
/>
|
||||
<motion.div
|
||||
animate={{ scale: [1.1, 1, 1.1], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="absolute bottom-[5%] right-[5%] w-[50vw] h-[50vw] bg-liquid-purple rounded-full blur-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The Image Container with Organic Border Radius */}
|
||||
<motion.div
|
||||
className="absolute inset-0 overflow-hidden bg-stone-100"
|
||||
style={{
|
||||
filter: "drop-shadow(0 20px 40px rgba(0,0,0,0.1))",
|
||||
willChange: "border-radius",
|
||||
}}
|
||||
animate={{
|
||||
borderRadius: [
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
"30% 60% 70% 40%/50% 60% 30% 60%",
|
||||
"60% 40% 30% 70%/60% 30% 70% 40%",
|
||||
],
|
||||
}}
|
||||
transition={{
|
||||
duration: 12,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
>
|
||||
{/* Use a plain <img> to fully bypass Next.js image optimizer (dev 400 issue). */}
|
||||
<img
|
||||
src="/images/me.jpg"
|
||||
alt="Dennis Konkol"
|
||||
className="absolute inset-0 w-full h-full object-cover scale-105 hover:scale-[1.08] transition-transform duration-1000 ease-out"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
{/* Glossy Overlay for Liquid Feel */}
|
||||
<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
|
||||
<div className="relative z-10 max-w-7xl mx-auto w-full pt-20">
|
||||
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-24">
|
||||
|
||||
{/* Left: Text Content */}
|
||||
<div className="flex-1 text-center lg:text-left space-y-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
|
||||
className="absolute -bottom-8 left-1/2 -translate-x-1/2 z-30"
|
||||
transition={{ duration: 0.5 }}
|
||||
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">
|
||||
dk<span className="text-red-500 font-extrabold">0</span>.dev
|
||||
</div>
|
||||
<span className="w-2.5 h-2.5 bg-emerald-500 rounded-full animate-pulse" />
|
||||
<span className="font-mono text-[10px] font-black uppercase tracking-[0.3em] text-stone-500">{getLabel("hero.badge", "Student & Self-Hoster")}</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Floating Badges - subtle animations */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.5, ease: "easeOut" }}
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
className="absolute -top-4 right-0 md:-right-4 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
||||
<h1 className="text-6xl md:text-[9.5rem] font-black tracking-tighter leading-[0.8] text-stone-900 dark:text-stone-50 uppercase">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.1 }}
|
||||
className="block"
|
||||
>
|
||||
{getLabel("hero.line1", "Building")}
|
||||
</motion.span>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-liquid-sky to-liquid-purple pb-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} />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 0.5, ease: "easeOut" }}
|
||||
whileHover={{ scale: 1.1, rotate: -5 }}
|
||||
className="absolute bottom-4 -left-4 md:-left-8 p-3 bg-white/95 backdrop-blur-md shadow-lg rounded-full text-stone-700 z-30"
|
||||
{t("description")}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
className="flex flex-col sm:flex-row items-center gap-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>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Title */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="mb-8 flex flex-col items-center justify-center relative"
|
||||
>
|
||||
<h1 className="text-5xl md:text-8xl font-bold tracking-tighter text-stone-900 mb-2">
|
||||
Dennis Konkol
|
||||
</h1>
|
||||
<h2 className="text-2xl md:text-4xl font-light tracking-wide text-stone-600 mt-2">
|
||||
Software Engineer
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
{/* Description */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="text-lg md:text-xl text-stone-700 mb-12 max-w-2xl mx-auto leading-relaxed"
|
||||
>
|
||||
{cmsDoc ? (
|
||||
<RichTextClient doc={cmsDoc} className="prose prose-stone max-w-none" />
|
||||
) : (
|
||||
<p>{t("description")}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Features */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
className="flex flex-wrap justify-center gap-4 mb-12"
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.text}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.5 + index * 0.1,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
}}
|
||||
whileHover={{ scale: 1.03, y: -3 }}
|
||||
className="flex items-center space-x-2 px-5 py-2.5 rounded-full bg-white/85 border-2 border-stone-300 shadow-md backdrop-blur-lg"
|
||||
>
|
||||
<feature.icon className="w-4 h-4 text-stone-800" />
|
||||
<span className="text-stone-800 font-semibold text-sm">
|
||||
{feature.text}
|
||||
</span>
|
||||
</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"
|
||||
{/* Right: The Photo */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1
|
||||
}}
|
||||
transition={{
|
||||
opacity: { duration: 1 },
|
||||
scale: { duration: 1 }
|
||||
}}
|
||||
className="relative w-72 h-72 md:w-[500px] md:h-[500px] shrink-0 mt-12 lg:mt-0"
|
||||
>
|
||||
<span className="text-cream">{t("ctaWork")}</span>
|
||||
<ArrowDown size={18} />
|
||||
</motion.a>
|
||||
<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" />
|
||||
<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)]">
|
||||
<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
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { ExternalLink, Github, ArrowRight, Calendar } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
import { Skeleton } from "./ui/Skeleton";
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
@@ -47,215 +25,105 @@ interface Project {
|
||||
|
||||
const Projects = () => {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("home.projects");
|
||||
useTranslations("home.projects");
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/projects?featured=true&published=true&limit=6",
|
||||
);
|
||||
const response = await fetch("/api/projects?featured=true&published=true&limit=6");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setProjects(data.projects || []);
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error loading projects:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Featured projects fetch failed:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
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"
|
||||
>
|
||||
<section id="projects" className="py-32 px-4 bg-stone-50 dark:bg-stone-950">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={fadeInUp}
|
||||
className="text-center mb-20"
|
||||
>
|
||||
<h2 className="text-4xl md:text-6xl font-bold mb-6 text-stone-900">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-lg text-stone-600 max-w-2xl mx-auto mt-4 font-light">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</motion.div>
|
||||
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-6">
|
||||
<div>
|
||||
<h2 className="text-4xl md:text-6xl font-black text-stone-900 dark:text-stone-50 tracking-tighter mb-4 uppercase">
|
||||
Selected Work<span className="text-liquid-mint">.</span>
|
||||
</h2>
|
||||
<p className="text-xl text-stone-500 max-w-xl font-light">
|
||||
Projects that pushed my boundaries.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
View Archive <ArrowUpRight className="group-hover:-translate-y-1 group-hover:translate-x-1 transition-transform" size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={staggerContainer}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
{loading ? (
|
||||
Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="space-y-6">
|
||||
<Skeleton className="aspect-[4/3] rounded-[2.5rem]" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-8 w-1/2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<motion.div
|
||||
key={project.id}
|
||||
variants={fadeInUp}
|
||||
whileHover={{ y: -8 }}
|
||||
className="group flex flex-col bg-white/40 backdrop-blur-xl rounded-2xl overflow-hidden border border-white/60 shadow-[0_4px_20px_rgba(0,0,0,0.02)] hover:shadow-[0_20px_40px_rgba(0,0,0,0.06)] transition-[box-shadow,border-color,background-color] duration-500"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="group relative"
|
||||
>
|
||||
{/* Project Cover / Image Area */}
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-stone-100">
|
||||
{project.imageUrl ? (
|
||||
<>
|
||||
<Link href={`/${locale}/projects/${project.slug}`} className="block">
|
||||
{/* Image Card */}
|
||||
<div className="relative aspect-[4/3] rounded-3xl overflow-hidden bg-stone-200 dark:bg-stone-900 mb-6">
|
||||
{project.imageUrl ? (
|
||||
<Image
|
||||
src={project.imageUrl}
|
||||
alt={project.title}
|
||||
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 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)}
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{/* Overlay on Hover */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-500" />
|
||||
</div>
|
||||
|
||||
{/* Text Content */}
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.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>
|
||||
);
|
||||
|
||||
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 utilities;
|
||||
|
||||
/* Grain Effect */
|
||||
.grain-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.04;
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Warm Brown & Off-White Palette */
|
||||
--background: #faf8f3; /* Warm off-white */
|
||||
@@ -26,8 +40,30 @@
|
||||
--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 {
|
||||
background: linear-gradient(135deg, rgba(250, 248, 243, 0.95) 0%, rgba(250, 248, 243, 0.92) 100%);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: "Inter", sans-serif;
|
||||
margin: 0;
|
||||
@@ -37,6 +73,7 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Custom Selection */
|
||||
@@ -52,35 +89,33 @@ html {
|
||||
|
||||
/* Liquid Glass Effects */
|
||||
.glass-panel {
|
||||
background: rgba(250, 248, 243, 0.75);
|
||||
background: var(--card);
|
||||
backdrop-filter: blur(20px) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(130%);
|
||||
border: 1px solid rgba(215, 204, 200, 0.6);
|
||||
box-shadow: 0 8px 32px rgba(62, 39, 35, 0.12);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
will-change: backdrop-filter;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: rgba(255, 252, 245, 0.85);
|
||||
background: var(--card);
|
||||
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:
|
||||
0 4px 6px -1px rgba(62, 39, 35, 0.06),
|
||||
0 2px 4px -1px rgba(62, 39, 35, 0.05),
|
||||
inset 0 0 30px rgba(255, 252, 245, 0.6);
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.06),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.6s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(255, 252, 245, 0.95);
|
||||
background: var(--card);
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(62, 39, 35, 0.15),
|
||||
0 10px 10px -5px rgba(62, 39, 35, 0.08),
|
||||
inset 0 0 30px rgba(255, 252, 245, 0.9);
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.15),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(215, 204, 200, 0.9);
|
||||
border-color: var(--ring);
|
||||
}
|
||||
|
||||
/* Typography & Headings */
|
||||
@@ -93,7 +128,7 @@ h6 {
|
||||
font-family: var(--font-playfair), Georgia, serif;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 700;
|
||||
color: #3e2723;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Improve text contrast - using foreground variable for WCAG AA compliance */
|
||||
@@ -154,34 +189,34 @@ div {
|
||||
/* Markdown Specifics for Blog/Projects */
|
||||
.markdown h1 {
|
||||
@apply text-4xl font-bold mb-6 tracking-tight;
|
||||
color: #3e2723;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.markdown h2 {
|
||||
@apply text-2xl font-semibold mt-8 mb-4 tracking-tight;
|
||||
color: #3e2723;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.markdown p {
|
||||
@apply mb-4 leading-relaxed;
|
||||
color: #4e342e;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.markdown a {
|
||||
@apply underline decoration-2 underline-offset-2 hover:opacity-80 transition-colors duration-300;
|
||||
color: #5d4037;
|
||||
text-decoration-color: #a1887f;
|
||||
color: var(--primary);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
.markdown ul {
|
||||
@apply list-disc list-inside mb-4 space-y-2;
|
||||
color: #4e342e;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.markdown code {
|
||||
@apply px-1.5 py-0.5 rounded text-sm font-mono;
|
||||
background: #efebe9;
|
||||
color: #3e2723;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
.markdown pre {
|
||||
@apply p-4 rounded-xl overflow-x-auto mb-6;
|
||||
background: #3e2723;
|
||||
color: #faf8f3;
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
/* Admin Dashboard Styles - Warm Brown Theme */
|
||||
|
||||
@@ -29,11 +29,12 @@ export default async function RootLayout({
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
</head>
|
||||
<body className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||
<div className="grain-overlay" aria-hidden="true" />
|
||||
<ShaderGradientBackground />
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
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 Footer from "../components/Footer";
|
||||
import Link from "next/link";
|
||||
@@ -15,7 +15,6 @@ export default function LegalNotice() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("common");
|
||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -24,114 +23,120 @@ export default function LegalNotice() {
|
||||
`/api/content/page?key=${encodeURIComponent("legal-notice")}&locale=${encodeURIComponent(locale)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
// Only use CMS content if it exists for the active locale.
|
||||
if (data?.content?.content && data?.content?.locale === locale) {
|
||||
setCmsDoc(data.content.content as JSONContent);
|
||||
setCmsTitle((data.content.title as string | null) ?? null);
|
||||
} else {
|
||||
setCmsDoc(null);
|
||||
setCmsTitle(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore; fallback to static content
|
||||
setCmsDoc(null);
|
||||
setCmsTitle(null);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen animated-bg">
|
||||
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||
<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
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-8"
|
||||
className="mb-20"
|
||||
>
|
||||
<Link
|
||||
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} />
|
||||
<span>{t("backToHome")}</span>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||
{cmsTitle || "Impressum"}
|
||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||
Legal<span className="text-liquid-mint">.</span>
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="glass-card p-8 rounded-2xl space-y-6"
|
||||
>
|
||||
{cmsDoc ? (
|
||||
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-gray-300 leading-relaxed">
|
||||
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Inhalte dieser Website</h2>
|
||||
<div className="space-y-2 text-gray-300">
|
||||
<p>
|
||||
<strong>Name:</strong> Dennis Konkol
|
||||
</p>
|
||||
<p>
|
||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
|
||||
</p>
|
||||
<p>
|
||||
<strong>E-Mail:</strong>{" "}
|
||||
<Link href="mailto:info@dki.one" className="text-blue-400 hover:text-blue-300 transition-colors">
|
||||
info@dk0.dev
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Website:</strong>{" "}
|
||||
<Link href="https://www.dk0.dev" className="text-blue-400 hover:text-blue-300 transition-colors">
|
||||
dk0.dev
|
||||
</Link>
|
||||
{/* Bento Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
|
||||
{/* Main Legal Content (Large Box) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
{cmsDoc ? (
|
||||
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||
<RichTextClient doc={cmsDoc} />
|
||||
</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">
|
||||
<Scale className="text-liquid-mint" size={28} /> Angaben gemäß § 5 TMG
|
||||
</h2>
|
||||
<div className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400 space-y-4">
|
||||
<p className="font-bold text-stone-900 dark:text-stone-100">Dennis Konkol</p>
|
||||
<p>Auf dem Ziegenbrink 2B</p>
|
||||
<p>49082 Osnabrück, Deutschland</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||
<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>
|
||||
</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 className="text-gray-300">
|
||||
<h2 className="text-2xl font-semibold mb-4">Haftung für Links</h2>
|
||||
<p className="leading-relaxed">
|
||||
Meine Website enthält Links auf externe Websites. Ich habe keinen Einfluss auf die Inhalte dieser
|
||||
Websites und kann daher keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der
|
||||
Betreiber oder Anbieter der Seiten verantwortlich. Jedoch überprüfe ich die verlinkten Seiten zum
|
||||
Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße. Bei Bekanntwerden von Rechtsverletzungen werde
|
||||
ich derartige Links umgehend entfernen.
|
||||
</p>
|
||||
{/* Meta Info Box */}
|
||||
<div className="bg-liquid-purple/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-purple/20 dark:border-stone-800/60">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Clock className="text-liquid-purple" size={20} />
|
||||
<div>
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-stone-400">Last Review</p>
|
||||
<p className="font-bold text-stone-900 dark:text-stone-100 text-sm">February 15, 2025</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">
|
||||
<h2 className="text-2xl font-semibold mb-4">Urheberrecht</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -1,150 +1,109 @@
|
||||
"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 { useRouter } from "next/navigation";
|
||||
import { Home, ArrowLeft, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function NotFound() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
||||
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');
|
||||
}
|
||||
};
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#faf8f3] p-4">
|
||||
<div className="w-full max-w-2xl">
|
||||
{/* Terminal-style 404 */}
|
||||
<div className="bg-[#3e2723] rounded-2xl shadow-2xl overflow-hidden border border-[#5d4037]">
|
||||
{/* Terminal Header */}
|
||||
<div className="bg-[#5d4037] px-4 py-3 flex items-center gap-2 border-b border-[#795548]">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#d84315]"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-[#bcaaa4]"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-[#a1887f]"></div>
|
||||
</div>
|
||||
<div className="ml-4 text-[#faf8f3] text-sm font-mono">
|
||||
terminal@portfolio ~ 404
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal Body */}
|
||||
<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>
|
||||
<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="max-w-7xl mx-auto w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 md:gap-8 max-w-5xl mx-auto">
|
||||
|
||||
{/* Main Error Card */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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>
|
||||
<div className="flex items-center gap-3 mb-12">
|
||||
<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">
|
||||
404
|
||||
</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-stone-400">Error Report</span>
|
||||
</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>
|
||||
|
||||
{/* Interactive Command Line */}
|
||||
<div className="flex items-center gap-2 border-t border-[#5d4037] pt-4">
|
||||
<span className="text-[#a1887f]">$</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCommand(input);
|
||||
setInput('');
|
||||
}
|
||||
}}
|
||||
placeholder="Type a command..."
|
||||
className="flex-1 bg-transparent text-[#faf8f3] outline-none placeholder:text-[#795548] font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-12 flex flex-wrap gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
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"
|
||||
>
|
||||
Return Home
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
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"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
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 Footer from "../components/Footer";
|
||||
import Link from "next/link";
|
||||
@@ -15,7 +15,6 @@ export default function PrivacyPolicy() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("common");
|
||||
const [cmsDoc, setCmsDoc] = useState<JSONContent | null>(null);
|
||||
const [cmsTitle, setCmsTitle] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -24,310 +23,125 @@ export default function PrivacyPolicy() {
|
||||
`/api/content/page?key=${encodeURIComponent("privacy-policy")}&locale=${encodeURIComponent(locale)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
// Only use CMS content if it exists for the active locale.
|
||||
if (data?.content?.content && data?.content?.locale === locale) {
|
||||
setCmsDoc(data.content.content as JSONContent);
|
||||
setCmsTitle((data.content.title as string | null) ?? null);
|
||||
} else {
|
||||
setCmsDoc(null);
|
||||
setCmsTitle(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore; fallback to static content
|
||||
setCmsDoc(null);
|
||||
setCmsTitle(null);
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen animated-bg">
|
||||
<div className="min-h-screen bg-[#fdfcf8] dark:bg-stone-950 transition-colors duration-500">
|
||||
<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
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="mb-8"
|
||||
className="mb-20"
|
||||
>
|
||||
<motion.a
|
||||
<Link
|
||||
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} />
|
||||
<span>{t("backToHome")}</span>
|
||||
</motion.a>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span className="font-bold uppercase tracking-widest text-xs">{t("backToHome")}</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 gradient-text">
|
||||
{cmsTitle || "Datenschutzerklärung"}
|
||||
<h1 className="text-6xl md:text-[10rem] font-black tracking-tighter text-stone-900 dark:text-stone-50 leading-[0.85] uppercase">
|
||||
Privacy<span className="text-liquid-purple">.</span>
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="glass-card p-8 rounded-2xl space-y-6 text-white"
|
||||
>
|
||||
{cmsDoc ? (
|
||||
<RichTextClient doc={cmsDoc} className="prose prose-invert max-w-none text-gray-300" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-gray-300 leading-relaxed">
|
||||
<p>
|
||||
Der Schutz Ihrer persönlichen Daten ist mir wichtig. In dieser Datenschutzerklärung informiere ich Sie
|
||||
über die Verarbeitung personenbezogener Daten im Rahmen meines Internet-Angebots.
|
||||
</p>
|
||||
{/* Bento Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
|
||||
{/* Main Privacy Text (Large) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="lg:col-span-8 bg-white dark:bg-stone-900 rounded-[3rem] p-10 md:p-16 border border-stone-200/60 dark:border-stone-800/60 shadow-sm"
|
||||
>
|
||||
{cmsDoc ? (
|
||||
<div className="prose prose-stone dark:prose-invert max-w-none text-lg md:text-xl font-light leading-relaxed">
|
||||
<RichTextClient doc={cmsDoc} />
|
||||
</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">
|
||||
<h2 className="text-2xl font-semibold mb-4">Verantwortlicher für die Datenverarbeitung</h2>
|
||||
<div className="space-y-2 text-gray-300">
|
||||
<p>
|
||||
<strong>Name:</strong> Dennis Konkol
|
||||
</p>
|
||||
<p>
|
||||
<strong>Adresse:</strong> Auf dem Ziegenbrink 2B, 49082 Osnabrück, Deutschland
|
||||
</p>
|
||||
<p>
|
||||
<strong>E-Mail:</strong>{" "}
|
||||
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="mailto:info@dk0.dev">
|
||||
info@dk0.dev
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Website:</strong>{" "}
|
||||
<Link className="text-blue-400 hover:text-blue-300 transition-colors" href="https://www.dk0.dev">
|
||||
dk0.dev
|
||||
</Link>
|
||||
<section>
|
||||
<h2 className="text-3xl font-black text-stone-900 dark:text-stone-100 mb-8 uppercase tracking-tight flex items-center gap-3">
|
||||
<Database className="text-liquid-sky" size={28} /> Datenerfassung
|
||||
</h2>
|
||||
<p className="text-xl font-light leading-relaxed text-stone-600 dark:text-stone-400">
|
||||
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>
|
||||
</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>
|
||||
<p className="mt-4">
|
||||
Diese Datenschutzerklärung gilt für die Verarbeitung personenbezogener Daten durch den oben genannten
|
||||
Verantwortlichen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-6">
|
||||
Erfassung allgemeiner Informationen beim Besuch meiner Website
|
||||
</h2>
|
||||
<div className="mt-2">
|
||||
Beim Zugriff auf meiner Website werden automatisch Informationen allgemeiner Natur erfasst. Diese
|
||||
beinhalten unter anderem:
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>IP-Adresse (in anonymisierter Form)</li>
|
||||
<li>Uhrzeit</li>
|
||||
<li>Browsertyp</li>
|
||||
<li>Verwendetes Betriebssystem</li>
|
||||
<li>Referrer-URL (die zuvor besuchte Seite)</li>
|
||||
</ul>
|
||||
<br />
|
||||
Diese Informationen werden anonymisiert erfasst und dienen ausschließlich statistischen Auswertungen.
|
||||
Rückschlüsse auf Ihre Person sind nicht möglich. Diese Daten werden verarbeitet, um:
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>die Inhalte meiner Website korrekt auszuliefern,</li>
|
||||
<li>die Inhalte meiner Website zu optimieren,</li>
|
||||
<li>die Systemsicherheit und -stabilität zu analysiern.</li>
|
||||
</ul>
|
||||
{/* Cookie Status Indicator */}
|
||||
<div className="bg-liquid-mint/5 dark:bg-stone-900 rounded-[3rem] p-10 border border-liquid-mint/20 dark:border-stone-800/60">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<p className="text-xs font-black uppercase tracking-widest text-stone-400">Security Check</p>
|
||||
</div>
|
||||
<p className="text-stone-900 dark:text-stone-50 font-bold mb-4">Your connection is private and secure.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('cookie-consent');
|
||||
window.location.reload();
|
||||
}}
|
||||
className="text-[10px] font-black uppercase tracking-widest border-b border-stone-300 dark:border-stone-700 pb-1 hover:text-liquid-mint transition-colors"
|
||||
>
|
||||
Reset Privacy Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</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/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"scripts/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
|
||||
202
jest.setup.ts
202
jest.setup.ts
@@ -1,155 +1,69 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import "whatwg-fetch";
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import { ToastProvider } from "@/components/Toast";
|
||||
import { Request, Response, Headers } from "node-fetch";
|
||||
|
||||
// Mock Next.js router
|
||||
jest.mock("next/navigation", () => ({
|
||||
useRouter() {
|
||||
return {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
prefetch: jest.fn(),
|
||||
back: jest.fn(),
|
||||
pathname: "/",
|
||||
query: {},
|
||||
asPath: "/",
|
||||
};
|
||||
},
|
||||
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 matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock next/image
|
||||
jest.mock("next/image", () => {
|
||||
return function Image({
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
return React.createElement("img", { src, alt, ...props });
|
||||
};
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
});
|
||||
|
||||
// Mock react-responsive-masonry if it's used
|
||||
jest.mock("react-responsive-masonry", () => {
|
||||
const MasonryComponent = function Masonry({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return React.createElement("div", { "data-testid": "masonry" }, children);
|
||||
};
|
||||
|
||||
const ResponsiveMasonryComponent = function ResponsiveMasonry({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-testid": "responsive-masonry" },
|
||||
children,
|
||||
);
|
||||
};
|
||||
// Polyfill Headers/Request/Response
|
||||
if (!global.Headers) {
|
||||
// @ts-expect-error - Polyfilling global Headers for jest environment
|
||||
global.Headers = Headers;
|
||||
}
|
||||
if (!global.Request) {
|
||||
// @ts-expect-error - Polyfilling global Request for jest environment
|
||||
global.Request = Request;
|
||||
}
|
||||
if (!global.Response) {
|
||||
// @ts-expect-error - Polyfilling global Response for jest environment
|
||||
global.Response = Response;
|
||||
}
|
||||
|
||||
// Mock NextResponse
|
||||
jest.mock('next/server', () => {
|
||||
const actual = jest.requireActual('next/server');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MasonryComponent,
|
||||
ResponsiveMasonry: ResponsiveMasonryComponent,
|
||||
...actual,
|
||||
NextResponse: {
|
||||
// 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
|
||||
const customRender = (ui: React.ReactElement, options = {}) =>
|
||||
render(ui, {
|
||||
wrapper: ({ children }) =>
|
||||
React.createElement(ToastProvider, null, children),
|
||||
...options,
|
||||
});
|
||||
|
||||
// Re-export everything
|
||||
export * from "@testing-library/react";
|
||||
export { customRender as render };
|
||||
// Env vars for tests
|
||||
process.env.DIRECTUS_URL = "http://localhost:8055";
|
||||
process.env.DIRECTUS_TOKEN = "test-token";
|
||||
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000";
|
||||
|
||||
521
lib/directus.ts
521
lib/directus.ts
@@ -24,7 +24,11 @@ function toDirectusLocale(locale: string): string {
|
||||
|
||||
interface FetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: any;
|
||||
body?: {
|
||||
query?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
async function directusRequest<T>(
|
||||
@@ -75,9 +79,9 @@ async function directusRequest<T>(
|
||||
}
|
||||
|
||||
return data?.data || null;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// 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') {
|
||||
console.error('Directus timeout');
|
||||
}
|
||||
@@ -85,66 +89,100 @@ async function directusRequest<T>(
|
||||
}
|
||||
// Andere Errors nur in dev loggen
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessage(key: string, locale: string): Promise<string | null> {
|
||||
// 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
|
||||
export async function getMessages(locale: string): Promise<Record<string, string>> {
|
||||
const directusLocale = toDirectusLocale(locale);
|
||||
|
||||
// GraphQL Query für Directus Native Translations
|
||||
// Hole alle translations, filter client-side da GraphQL filter komplex ist
|
||||
const query = `
|
||||
query {
|
||||
messages(filter: {key: {_eq: "${key}"}}, limit: 1) {
|
||||
messages {
|
||||
key
|
||||
translations {
|
||||
value
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
languages_code { code }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await directusRequest(
|
||||
'',
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const messages = (result as any)?.messages;
|
||||
if (!messages || messages.length === 0) {
|
||||
return null;
|
||||
const result = await directusRequest('', { body: { query } });
|
||||
interface MessageData {
|
||||
messages: Array<{
|
||||
key: string;
|
||||
translations?: Array<{
|
||||
languages_code?: { code: string };
|
||||
value?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
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 translation = translations.find((t: any) =>
|
||||
t.languages_code?.code === directusLocale
|
||||
);
|
||||
|
||||
const translation = translations.find((t) => t.languages_code?.code === directusLocale);
|
||||
return translation?.value || null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch message ${key} (${locale}):`, error);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
export interface ContentPage {
|
||||
slug: string;
|
||||
content?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export async function getContentPage(
|
||||
slug: string,
|
||||
locale: string
|
||||
): Promise<any | null> {
|
||||
): Promise<ContentPage | null> {
|
||||
const directusLocale = toDirectusLocale(locale);
|
||||
const query = `
|
||||
query {
|
||||
@@ -172,7 +210,10 @@ export async function getContentPage(
|
||||
{ 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) {
|
||||
// Try without locale filter
|
||||
const fallbackQuery = `
|
||||
@@ -190,7 +231,7 @@ export async function getContentPage(
|
||||
}
|
||||
`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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>> = {
|
||||
'en-US': {
|
||||
frontend: 'Frontend & Mobile',
|
||||
@@ -291,7 +325,19 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
{ 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 (process.env.NODE_ENV === 'development') {
|
||||
@@ -315,15 +361,25 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
);
|
||||
|
||||
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') {
|
||||
console.log('[getTechStack] Fetched items:', allItems.length);
|
||||
}
|
||||
|
||||
// Group items by category
|
||||
const categoriesWithItems = categories.map((cat: any) => {
|
||||
const categoryItems = allItems.filter((item: any) =>
|
||||
const categoriesWithItems = categories.map((cat) => {
|
||||
const categoryItems = allItems.filter((item) =>
|
||||
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) => ({
|
||||
id: `fallback-${cat.key}-${idx}`,
|
||||
name: name,
|
||||
category: cat.id,
|
||||
url: undefined,
|
||||
icon_url: undefined,
|
||||
sort: idx + 1
|
||||
@@ -349,7 +406,7 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
icon: cat.icon,
|
||||
sort: cat.sort,
|
||||
name: cat.translations?.[0]?.name || categoryNames[directusLocale]?.[cat.key] || cat.key,
|
||||
items: itemsToUse.map((item: any) => ({
|
||||
items: itemsToUse.map((item: TechStackItem) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
@@ -360,8 +417,8 @@ export async function getTechStack(locale: string): Promise<TechStackCategory[]
|
||||
});
|
||||
|
||||
return categoriesWithItems;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch tech stack (${locale}):`, error);
|
||||
} catch (_error) {
|
||||
console.error(`Failed to fetch tech stack (${locale}):`, _error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -404,12 +461,23 @@ export async function getHobbies(locale: string): Promise<Hobby[] | null> {
|
||||
{ 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hobbies.map((hobby: any) => ({
|
||||
return hobbies.map((hobby) => ({
|
||||
id: hobby.id,
|
||||
key: hobby.key,
|
||||
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
|
||||
export interface Project {
|
||||
id: string;
|
||||
id: string | number; // Allow both string (from Directus) and number (from Prisma)
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -438,6 +596,8 @@ export interface Project {
|
||||
future_improvements?: string;
|
||||
github_url?: string;
|
||||
live_url?: string;
|
||||
button_live_label?: string;
|
||||
button_github_label?: string;
|
||||
image_url?: string;
|
||||
demo_video_url?: string;
|
||||
performance_metrics?: string;
|
||||
@@ -527,6 +687,8 @@ export async function getProjects(
|
||||
content
|
||||
meta_description
|
||||
keywords
|
||||
button_live_label
|
||||
button_github_label
|
||||
languages_code { code }
|
||||
}
|
||||
}
|
||||
@@ -539,19 +701,52 @@ export async function getProjects(
|
||||
{ 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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return projects.map((proj: any) => {
|
||||
return projects.map((proj) => {
|
||||
const trans =
|
||||
proj.translations?.find((t: any) => t.languages_code?.code === directusLocale) ||
|
||||
proj.translations?.find((t) => t.languages_code?.code === directusLocale) ||
|
||||
proj.translations?.[0] ||
|
||||
{};
|
||||
|
||||
// Parse JSON string fields if needed
|
||||
const parseTags = (tags: any) => {
|
||||
const parseTags = (tags: string[] | string | undefined): string[] => {
|
||||
if (!tags) return [];
|
||||
if (Array.isArray(tags)) return tags;
|
||||
if (typeof tags === 'string') {
|
||||
@@ -563,7 +758,7 @@ export async function getProjects(
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
id: proj.id,
|
||||
slug: proj.slug,
|
||||
@@ -579,6 +774,8 @@ export async function getProjects(
|
||||
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,
|
||||
performance_metrics: proj.performance_metrics,
|
||||
@@ -589,8 +786,210 @@ export async function getProjects(
|
||||
updated_at: proj.date_updated
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch projects (${locale}):`, error);
|
||||
} catch (_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
* - Caches results (5 min TTL)
|
||||
*/
|
||||
|
||||
import { getMessage, getContentPage } from './directus';
|
||||
import { getMessage, getContentPage, ContentPage } from './directus';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import deMessages from '@/messages/de.json';
|
||||
|
||||
const jsonFallback = { en: enMessages, de: deMessages };
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
function getCached(key: string): any | null {
|
||||
function getCached(key: string): unknown | null {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() > hit.expires) {
|
||||
@@ -38,7 +38,7 @@ export async function getLocalizedMessage(
|
||||
): Promise<string> {
|
||||
const cacheKey = `msg:${key}:${locale}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
if (cached !== null) return cached as string;
|
||||
|
||||
// Try Directus with requested locale
|
||||
const dbValue = await getMessage(key, locale);
|
||||
@@ -84,11 +84,11 @@ export async function getLocalizedMessage(
|
||||
export async function getLocalizedContent(
|
||||
slug: string,
|
||||
locale: string
|
||||
): Promise<any | null> {
|
||||
): Promise<ContentPage | null> {
|
||||
const cacheKey = `page:${slug}:${locale}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
if (cached === null && cache.has(cacheKey)) return null; // Already checked, not found
|
||||
if (cached !== null) return cached as ContentPage;
|
||||
if (cache.has(cacheKey)) return null; // Already checked, not found
|
||||
|
||||
// Try Directus with requested locale
|
||||
const dbPage = await getContentPage(slug, locale);
|
||||
@@ -115,14 +115,18 @@ export async function getLocalizedContent(
|
||||
* Helper: Get nested value from object
|
||||
* 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('.');
|
||||
let value = obj;
|
||||
let value: unknown = obj;
|
||||
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;
|
||||
}
|
||||
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";
|
||||
|
||||
export function richTextToSafeHtml(doc: JSONContent): string {
|
||||
const raw = generateHTML(doc, [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
TextStyle,
|
||||
FontFamily,
|
||||
Color,
|
||||
Highlight,
|
||||
]);
|
||||
if (!doc || typeof doc !== "object" || Object.keys(doc).length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return sanitizeHtml(raw, {
|
||||
allowedTags: [
|
||||
"p",
|
||||
"br",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"blockquote",
|
||||
"strong",
|
||||
"em",
|
||||
"u",
|
||||
"a",
|
||||
"ul",
|
||||
"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)$/],
|
||||
// Ensure type is present to satisfy Tiptap requirement
|
||||
const typedDoc = { ...doc };
|
||||
if (!typedDoc.type) {
|
||||
typedDoc.type = "doc";
|
||||
}
|
||||
|
||||
// Ensure content is an array
|
||||
if (!typedDoc.content) {
|
||||
typedDoc.content = [];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = generateHTML(typedDoc, [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: false,
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
TextStyle,
|
||||
FontFamily,
|
||||
Color,
|
||||
Highlight,
|
||||
]);
|
||||
|
||||
return sanitizeHtml(raw, {
|
||||
allowedTags: [
|
||||
"p",
|
||||
"br",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"blockquote",
|
||||
"strong",
|
||||
"em",
|
||||
"u",
|
||||
"a",
|
||||
"ul",
|
||||
"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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Combine tailwind classes safely
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce helper to prevent duplicate function calls
|
||||
* @param func - The function to debounce
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"contact": "Kontakt"
|
||||
},
|
||||
"common": {
|
||||
"back": "Zurück",
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"backToProjects": "Zurück zu den Projekten",
|
||||
"viewAllProjects": "Alle Projekte ansehen",
|
||||
@@ -30,17 +31,17 @@
|
||||
"f2": "Docker Swarm & CI/CD",
|
||||
"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",
|
||||
"ctaContact": "Kontakt"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über mich",
|
||||
"p1": "Hi, ich bin Dennis – Student und leidenschaftlicher Self-Hoster aus Osnabrück.",
|
||||
"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.",
|
||||
"p3": "Wenn ich nicht code oder an Servern schraube, findest du mich beim Gaming, Joggen oder beim Experimentieren mit Automationen.",
|
||||
"funFactTitle": "Fun Fact",
|
||||
"funFactBody": "Auch wenn ich viel automatisiere, nutze ich für Kalender & Notizen noch Stift und Papier – das hilft mir beim Fokus.",
|
||||
"title": "Hinter den Kulissen",
|
||||
"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": "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 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": "Hardcore analog",
|
||||
"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",
|
||||
"hobbiesTitle": "Wenn ich nicht code",
|
||||
"techStack": {
|
||||
@@ -63,6 +64,19 @@
|
||||
"currentlyReading": {
|
||||
"title": "Aktuell am Lesen",
|
||||
"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": {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"contact": "Contact"
|
||||
},
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"backToHome": "Back to Home",
|
||||
"backToProjects": "Back to Projects",
|
||||
"viewAllProjects": "View All Projects",
|
||||
@@ -31,17 +32,17 @@
|
||||
"f2": "Docker Swarm & CI/CD",
|
||||
"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"
|
||||
"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 Projects",
|
||||
"ctaContact": "Get in touch"
|
||||
},
|
||||
"about": {
|
||||
"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.",
|
||||
"title": "Behind the Code",
|
||||
"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": "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 the servers are running smoothly, you'll find me jogging through the city, gaming, or tinkering with new automation workflows in n8n.",
|
||||
"funFactTitle": "Hardcore Analog",
|
||||
"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",
|
||||
"hobbiesTitle": "When I'm Not Coding",
|
||||
"techStack": {
|
||||
@@ -64,6 +65,19 @@
|
||||
"currentlyReading": {
|
||||
"title": "Currently Reading",
|
||||
"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": {
|
||||
|
||||
@@ -60,11 +60,27 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
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: (config, { dev, isServer }) => {
|
||||
webpack: (config, { dev, isServer, webpack }) => {
|
||||
// Fix for module resolution issues
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
@@ -91,6 +107,14 @@ const nextConfig: NextConfig = {
|
||||
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 =
|
||||
process.env.NODE_ENV === "production"
|
||||
? // 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';"
|
||||
: // Dev CSP: allow eval for tooling compatibility
|
||||
"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'; 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, 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: 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 [
|
||||
{
|
||||
|
||||
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",
|
||||
"next": "^15.5.7",
|
||||
"next-intl": "^4.7.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
@@ -104,7 +105,7 @@
|
||||
"@types/react-responsive-masonry": "^2.6.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.7",
|
||||
@@ -112,10 +113,10 @@
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"nodemailer-mock": "^2.0.9",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss": "^8.5.6",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.20.5",
|
||||
"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