locale upgrade
This commit is contained in:
269
DIRECTUS_CHECKLIST.md
Normal file
269
DIRECTUS_CHECKLIST.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
# Directus CMS – Eingabe-Checkliste
|
||||||
|
|
||||||
|
## Collections und Struktur
|
||||||
|
|
||||||
|
Du hast zwei Collections in Directus:
|
||||||
|
1. **messages** – kurze UI-Texte (Keys mit Werten)
|
||||||
|
2. **content_pages** – längere Abschnitte (Slug mit Rich Text)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collection: messages
|
||||||
|
|
||||||
|
Alle folgenden Einträge in Directus anlegen. Format:
|
||||||
|
| key | locale | value |
|
||||||
|
|
||||||
|
### Navigation & Header
|
||||||
|
```
|
||||||
|
nav.home | en | Home
|
||||||
|
nav.home | de | Startseite
|
||||||
|
nav.about | en | About
|
||||||
|
nav.about | de | Über mich
|
||||||
|
nav.projects | en | Projects
|
||||||
|
nav.projects | de | Projekte
|
||||||
|
nav.contact | en | Contact
|
||||||
|
nav.contact | de | Kontakt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
```
|
||||||
|
footer.role | en | Software Engineer
|
||||||
|
footer.role | de | Software Engineer
|
||||||
|
footer.madeIn | en | Made in Germany
|
||||||
|
footer.madeIn | de | Made in Germany
|
||||||
|
footer.legalNotice | en | Legal notice
|
||||||
|
footer.legalNotice | de | Impressum
|
||||||
|
footer.privacyPolicy | en | Privacy policy
|
||||||
|
footer.privacyPolicy | de | Datenschutz
|
||||||
|
footer.privacySettings| en | Privacy settings
|
||||||
|
footer.privacySettings| de | Datenschutz-Einstellungen
|
||||||
|
footer.privacySettingsTitle | en | Show privacy settings banner again
|
||||||
|
footer.privacySettingsTitle | de | Datenschutz-Banner wieder anzeigen
|
||||||
|
footer.builtWith | en | Built with
|
||||||
|
footer.builtWith | de | Built with
|
||||||
|
```
|
||||||
|
|
||||||
|
### Home – Hero
|
||||||
|
```
|
||||||
|
home.hero.features.f1 | en | Next.js & Flutter
|
||||||
|
home.hero.features.f1 | de | Next.js & Flutter
|
||||||
|
home.hero.features.f2 | en | Docker Swarm & CI/CD
|
||||||
|
home.hero.features.f2 | de | Docker Swarm & CI/CD
|
||||||
|
home.hero.features.f3 | en | Self-Hosted Infrastructure
|
||||||
|
home.hero.features.f3 | de | Self-Hosted Infrastruktur
|
||||||
|
```
|
||||||
|
|
||||||
|
### Home – About
|
||||||
|
```
|
||||||
|
home.about.title | en | About Me
|
||||||
|
home.about.title | de | Über mich
|
||||||
|
home.about.techStackTitle | en | My Tech Stack
|
||||||
|
home.about.techStackTitle | de | Mein Tech Stack
|
||||||
|
home.about.hobbiesTitle | en | When I'm Not Coding
|
||||||
|
home.about.hobbiesTitle | de | Wenn ich nicht code
|
||||||
|
home.about.currentlyReading.title | en | Currently Reading
|
||||||
|
home.about.currentlyReading.title | de | Aktuell am Lesen
|
||||||
|
home.about.currentlyReading.progress | en | Progress
|
||||||
|
home.about.currentlyReading.progress | de | Fortschritt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Home – Projects (List)
|
||||||
|
```
|
||||||
|
home.projects.title | en | Selected Works
|
||||||
|
home.projects.title | de | Ausgewählte Projekte
|
||||||
|
home.projects.subtitle | en | A collection of projects I've worked on...
|
||||||
|
home.projects.subtitle | de | Eine Auswahl an Projekten, an denen ich gearbeitet habe...
|
||||||
|
home.projects.featured | en | Featured
|
||||||
|
home.projects.featured | de | Hervorgehoben
|
||||||
|
home.projects.viewAll | en | View All Projects
|
||||||
|
home.projects.viewAll | de | Alle Projekte ansehen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Home – Contact
|
||||||
|
```
|
||||||
|
home.contact.title | en | Contact Me
|
||||||
|
home.contact.title | de | Kontakt
|
||||||
|
home.contact.subtitle | en | Interested in working together...
|
||||||
|
home.contact.subtitle | de | Du willst zusammenarbeiten...
|
||||||
|
home.contact.getInTouch | en | Get In Touch
|
||||||
|
home.contact.getInTouch | de | Melde dich
|
||||||
|
home.contact.getInTouchBody | en | I'm always available to discuss...
|
||||||
|
home.contact.getInTouchBody | de | Ich bin immer offen für neue Chancen...
|
||||||
|
home.contact.info.email | en | Email
|
||||||
|
home.contact.info.email | de | E-Mail
|
||||||
|
home.contact.info.location | en | Location
|
||||||
|
home.contact.info.location | de | Ort
|
||||||
|
home.contact.info.locationValue | en | Osnabrück, Germany
|
||||||
|
home.contact.info.locationValue | de | Osnabrück, Deutschland
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common
|
||||||
|
```
|
||||||
|
common.backToHome | en | Back to Home
|
||||||
|
common.backToHome | de | Zurück zur Startseite
|
||||||
|
common.backToProjects | en | Back to Projects
|
||||||
|
common.backToProjects | de | Zurück zu den Projekten
|
||||||
|
common.viewAllProjects | en | View All Projects
|
||||||
|
common.viewAllProjects | de | Alle Projekte ansehen
|
||||||
|
common.loading | en | Loading...
|
||||||
|
common.loading | de | Lädt...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projects – List
|
||||||
|
```
|
||||||
|
projects.list.title | en | My Projects
|
||||||
|
projects.list.title | de | Meine Projekte
|
||||||
|
projects.list.intro | en | Explore my portfolio...
|
||||||
|
projects.list.intro | de | Stöbere durch mein Portfolio...
|
||||||
|
projects.list.searchPlaceholder | en | Search projects...
|
||||||
|
projects.list.searchPlaceholder | de | Projekte durchsuchen...
|
||||||
|
projects.list.all | en | All
|
||||||
|
projects.list.all | de | Alle
|
||||||
|
projects.list.noResults | en | No projects found...
|
||||||
|
projects.list.noResults | de | Keine Projekte passen...
|
||||||
|
projects.list.clearFilters | en | Clear filters
|
||||||
|
projects.list.clearFilters | de | Filter zurücksetzen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Projects – Detail
|
||||||
|
```
|
||||||
|
projects.detail.links | en | Project Links
|
||||||
|
projects.detail.links | de | Projektlinks
|
||||||
|
projects.detail.liveDemo | en | Live Demo
|
||||||
|
projects.detail.liveDemo | de | Live-Demo
|
||||||
|
projects.detail.liveNotAvailable | en | Live demo not available
|
||||||
|
projects.detail.liveNotAvailable | de | Keine Live-Demo verfügbar
|
||||||
|
projects.detail.viewSource | en | View Source
|
||||||
|
projects.detail.viewSource | de | Quellcode ansehen
|
||||||
|
projects.detail.techStack | en | Tech Stack
|
||||||
|
projects.detail.techStack | de | Tech-Stack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consent & Privacy
|
||||||
|
```
|
||||||
|
consent.title | en | Privacy settings
|
||||||
|
consent.title | de | Datenschutz-Einstellungen
|
||||||
|
consent.description | en | We use optional services...
|
||||||
|
consent.description | de | Wir nutzen optionale Dienste...
|
||||||
|
consent.essential | en | Essential
|
||||||
|
consent.essential | de | Essentiell
|
||||||
|
consent.analytics | en | Analytics
|
||||||
|
consent.analytics | de | Analytics
|
||||||
|
consent.chat | en | Chatbot
|
||||||
|
consent.chat | de | Chatbot
|
||||||
|
consent.alwaysOn | en | Always on
|
||||||
|
consent.alwaysOn | de | Immer aktiv
|
||||||
|
consent.acceptAll | en | Accept all
|
||||||
|
consent.acceptAll | de | Alles akzeptieren
|
||||||
|
consent.acceptSelected | en | Accept selected
|
||||||
|
consent.acceptSelected | de | Auswahl akzeptieren
|
||||||
|
consent.rejectAll | en | Reject all
|
||||||
|
consent.rejectAll | de | Alles ablehnen
|
||||||
|
consent.hide | en | Hide
|
||||||
|
consent.hide | de | Ausblenden
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Collection: content_pages
|
||||||
|
|
||||||
|
Diese sind für **längere Inhalte**. Nutze den Rich-Text-Editor in Directus oder Markdown.
|
||||||
|
|
||||||
|
### Home – Hero (langere Beschreibung)
|
||||||
|
- **slug**: home-hero
|
||||||
|
- **locale**: en / de
|
||||||
|
- **title** (optional): Hero Section Description
|
||||||
|
- **content**: Längerer Text/Rich Text (ersetzen die kurze beschreibung)
|
||||||
|
|
||||||
|
Beispiel EN:
|
||||||
|
> "I'm a passionate software engineer and self-hoster from Osnabrück, Germany. I build full-stack web applications with Next.js, create mobile solutions with Flutter, and love exploring DevOps. I run my own infrastructure and automate deployments with CI/CD."
|
||||||
|
|
||||||
|
Beispiel DE:
|
||||||
|
> "Ich bin ein leidenschaftlicher Softwareentwickler und Self-Hoster aus Osnabrück. Ich entwickle Full-Stack Web-Apps mit Next.js, mobile Apps mit Flutter und bin begeistert von DevOps. Ich betreibe meine eigene Infrastruktur und automatisiere Deployments."
|
||||||
|
|
||||||
|
### Home – About (längere Inhalte)
|
||||||
|
- **slug**: home-about
|
||||||
|
- **locale**: en / de
|
||||||
|
- **content**: Längerer Fließtext über mich
|
||||||
|
|
||||||
|
### Home – Projects Intro
|
||||||
|
- **slug**: home-projects
|
||||||
|
- **locale**: en / de
|
||||||
|
- **content**: Intro-Text vor der Projekt-Liste
|
||||||
|
|
||||||
|
### Home – Contact Intro
|
||||||
|
- **slug**: home-contact
|
||||||
|
- **locale**: en / de
|
||||||
|
- **content**: Intro vor dem Kontakt-Formular
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wie du es in Directus eingeben kannst:
|
||||||
|
|
||||||
|
### Schritt 1: messages Collection
|
||||||
|
1. Gehe in Directus → **messages**.
|
||||||
|
2. Klick "Create New" (oder "+").
|
||||||
|
3. Füll aus:
|
||||||
|
- **key**: z. B. "nav.home"
|
||||||
|
- **locale**: Dropdown → "en" oder "de"
|
||||||
|
- **value**: Der Text (z. B. "Home")
|
||||||
|
4. Speichern. Wiederholen für alle Keys oben.
|
||||||
|
|
||||||
|
### Schritt 2: content_pages Collection
|
||||||
|
1. Gehe in Directus → **content_pages**.
|
||||||
|
2. Klick "Create New".
|
||||||
|
3. Füll aus:
|
||||||
|
- **slug**: z. B. "home-hero"
|
||||||
|
- **locale**: "en" oder "de"
|
||||||
|
- **title** (optional): "Hero Section" oder leer
|
||||||
|
- **content**: Markdown/Rich Text eingeben
|
||||||
|
4. Speichern. Wiederholen für andere Seiten.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Im Code: Texte nutzen
|
||||||
|
|
||||||
|
### Kurze Keys (aus messages):
|
||||||
|
```tsx
|
||||||
|
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
||||||
|
|
||||||
|
const text = await getLocalizedMessage('nav.home', locale);
|
||||||
|
// text = "Home" (oder fallback aus JSON)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Längere Inhalte (aus content_pages):
|
||||||
|
```tsx
|
||||||
|
import { getLocalizedContent } from '@/lib/i18n-loader';
|
||||||
|
|
||||||
|
const page = await getLocalizedContent('home-hero', locale);
|
||||||
|
// page.content = "Längerer Fließtext..."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick-Test:
|
||||||
|
|
||||||
|
1. Lege in Directus **einen** Key in messages an:
|
||||||
|
- key: "test"
|
||||||
|
- locale: "en"
|
||||||
|
- value: "Hello from Directus"
|
||||||
|
|
||||||
|
2. Im Code:
|
||||||
|
```tsx
|
||||||
|
const text = await getLocalizedMessage('test', 'en');
|
||||||
|
console.log(text); // sollte "Hello from Directus" loggen
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Wenn das funktioniert: Alle anderen Keys eintragen!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hinweise:
|
||||||
|
|
||||||
|
- **Keys** sollten mit `.` strukturiert sein (z. B. `nav.home`, `home.about.title`).
|
||||||
|
- **Locale** ist immer "en" oder "de" (enum).
|
||||||
|
- **Fallback**: Wenn ein Key in Directus fehlt, nutzt der Code die `messages/*.json` Dateien.
|
||||||
|
- **Caching**: Texte werden 5 Minuten gecacht. Um Cache zu leeren: `clearI18nCache()` im Code oder Server restart.
|
||||||
|
- **Rich Text**: Im `content_pages` Feld kannst du Markdown oder den Rich-Text-Editor nutzen.
|
||||||
|
|
||||||
|
Viel Spaß! 🚀
|
||||||
221
DIRECTUS_MIGRATION.md
Normal file
221
DIRECTUS_MIGRATION.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Directus Integration - Migration Guide
|
||||||
|
|
||||||
|
## 🎯 Was wurde geändert?
|
||||||
|
|
||||||
|
Das Portfolio nutzt jetzt **Directus als CMS** für alle Texte. Die Integration ist **hybrid**:
|
||||||
|
- ✅ **Directus** (primär) → Texte werden aus Directus CMS geladen
|
||||||
|
- ✅ **JSON Fallback** (sekundär) → Falls Directus nicht erreichbar, nutzen wir messages/*.json
|
||||||
|
|
||||||
|
## 📁 Neue Dateien
|
||||||
|
|
||||||
|
### Core Infrastructure
|
||||||
|
- `lib/directus.ts` - REST Client für Directus (nutzt `de-DE`, `en-US`)
|
||||||
|
- `lib/i18n-loader.ts` - Lädt Texte mit Fallback-Chain
|
||||||
|
- `lib/translations-loader.ts` - Batch-Loader für alle Sections
|
||||||
|
- `types/translations.ts` - TypeScript Types für alle Translation Objects
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- `app/components/Header.server.tsx` - Server Wrapper für Header
|
||||||
|
- `app/components/HeaderClient.tsx` - Client Implementation mit Props
|
||||||
|
- `app/components/ClientWrappers.tsx` - Wrapper für Hero, About, Projects, Contact, Footer
|
||||||
|
- `app/_ui/HomePageServer.tsx` - Server Component lädt alle Translations
|
||||||
|
|
||||||
|
## 🔄 Architektur
|
||||||
|
|
||||||
|
### Vorher (next-intl only)
|
||||||
|
```
|
||||||
|
Client Component → useTranslations("nav") → JSON File
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jetzt (Directus + Fallback)
|
||||||
|
```
|
||||||
|
Server Component → getNavTranslations(locale)
|
||||||
|
→ Directus API (de-DE/en-US)
|
||||||
|
→ Falls nicht gefunden: JSON File (de/en)
|
||||||
|
→ Props an Client Component
|
||||||
|
Client Component → Nutzt translations aus Props
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Directus Setup
|
||||||
|
|
||||||
|
### 1. Collection: `messages`
|
||||||
|
|
||||||
|
**Felder:**
|
||||||
|
- `id` (Primary Key, UUID, auto)
|
||||||
|
- `key` (String, required) - z.B. "nav.home"
|
||||||
|
- `locale` (String, required) - **WICHTIG:** `de-DE` oder `en-US` (mit `-`)
|
||||||
|
- `value` (Text, required) - Der übersetzte Text
|
||||||
|
- `translations` (Translations) - **Directus Native Translations Feature**
|
||||||
|
|
||||||
|
**WICHTIG:** Du hast zwei Optionen:
|
||||||
|
|
||||||
|
#### Option A: Directus Native Translations (Empfohlen)
|
||||||
|
1. Aktiviere "Translations" für `messages` Collection
|
||||||
|
2. Definiere `de-DE` und `en-US` als Languages
|
||||||
|
3. Felder: `key` (unique), `value` (translatable)
|
||||||
|
4. Pro Key nur ein Eintrag, Directus managed Translations intern
|
||||||
|
|
||||||
|
#### Option B: Flat Structure (Einfacher)
|
||||||
|
1. Keine Translations Feature
|
||||||
|
2. Felder: `key` + `locale` + `value`
|
||||||
|
3. Pro Key/Locale Kombination ein Eintrag
|
||||||
|
4. Beispiel:
|
||||||
|
- Row 1: key="nav.home", locale="de-DE", value="Startseite"
|
||||||
|
- Row 2: key="nav.home", locale="en-US", value="Home"
|
||||||
|
|
||||||
|
### 2. Collection: `content_pages` (Optional)
|
||||||
|
|
||||||
|
Für längere Inhalte (z.B. Datenschutz, Impressum):
|
||||||
|
|
||||||
|
**Felder:**
|
||||||
|
- `id` (Primary Key, UUID)
|
||||||
|
- `slug` (String, unique) - z.B. "privacy-policy"
|
||||||
|
- `locale` (String) - `de-DE` oder `en-US`
|
||||||
|
- `title` (String)
|
||||||
|
- `content` (Rich Text oder Long Text)
|
||||||
|
|
||||||
|
### 3. Permissions
|
||||||
|
|
||||||
|
**Public Role:**
|
||||||
|
- `messages`: Read access (alle Felder)
|
||||||
|
- `content_pages`: Read access (alle Felder)
|
||||||
|
|
||||||
|
## 📝 Keys eintragen
|
||||||
|
|
||||||
|
Alle Keys aus `DIRECTUS_CHECKLIST.md` müssen in Directus eingetragen werden.
|
||||||
|
|
||||||
|
**Beispiel Keys:**
|
||||||
|
```
|
||||||
|
nav.home
|
||||||
|
nav.about
|
||||||
|
nav.projects
|
||||||
|
nav.contact
|
||||||
|
home.hero.greeting
|
||||||
|
home.hero.name
|
||||||
|
home.hero.role
|
||||||
|
home.hero.description
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** Keys sind **dot-separated** (wie in JSON), aber **Locale nutzt `-`**:
|
||||||
|
- ✅ `key="nav.home"`, `locale="de-DE"`
|
||||||
|
- ❌ `key="nav_home"`, `locale="de"`
|
||||||
|
|
||||||
|
## 🔧 Environment Variables
|
||||||
|
|
||||||
|
In `.env.local`:
|
||||||
|
```bash
|
||||||
|
DIRECTUS_URL=https://cms.dk0.dev
|
||||||
|
DIRECTUS_STATIC_TOKEN=ogUMcHCa1CAYU1YifsoeJ_7V76o1atYG
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Wie funktioniert's?
|
||||||
|
|
||||||
|
### 1. Seite wird geladen
|
||||||
|
```tsx
|
||||||
|
// app/[locale]/page.tsx
|
||||||
|
export default async function Page({ params }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
return <HomePageServer locale={locale} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Server Component lädt Translations
|
||||||
|
```tsx
|
||||||
|
// app/_ui/HomePageServer.tsx
|
||||||
|
export default async function HomePageServer({ locale }) {
|
||||||
|
const heroT = await getHeroTranslations(locale);
|
||||||
|
// ...
|
||||||
|
return <HeroClient locale={locale} translations={heroT} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Translation Loader fetcht von Directus
|
||||||
|
```tsx
|
||||||
|
// lib/translations-loader.ts
|
||||||
|
export async function getHeroTranslations(locale: string) {
|
||||||
|
// Batch-Load aus Directus
|
||||||
|
// locale='de' wird zu 'de-DE' gemapped
|
||||||
|
const values = await Promise.all([...]);
|
||||||
|
return { greeting, name, role, ... };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Client Component nutzt Props
|
||||||
|
```tsx
|
||||||
|
// app/components/ClientWrappers.tsx
|
||||||
|
export function HeroClient({ locale, translations }) {
|
||||||
|
// Konvertiert zu next-intl Format
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<Hero />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Fallback Chain
|
||||||
|
|
||||||
|
Für jeden Key wird gesucht:
|
||||||
|
1. **Directus (requested locale)** - z.B. `de-DE`
|
||||||
|
2. **Directus (EN fallback)** - Falls nicht gefunden: `en-US`
|
||||||
|
3. **JSON (normalized locale)** - Falls Directus down: `messages/de.json`
|
||||||
|
4. **JSON (EN fallback)** - Falls Key nicht existiert: `messages/en.json`
|
||||||
|
5. **Key selbst** - Als letzter Fallback: return "nav.home"
|
||||||
|
|
||||||
|
## 🎨 Cache
|
||||||
|
|
||||||
|
- In-Memory Cache mit 5 min TTL
|
||||||
|
- Cache Key: `msg:${key}:${locale}`
|
||||||
|
- Läuft im Server Memory (nicht persistent)
|
||||||
|
- Bei Deploy/Restart wird Cache geleert
|
||||||
|
|
||||||
|
## ✅ Testing
|
||||||
|
|
||||||
|
1. **Mit Directus:** Trage einen Test-Key ein:
|
||||||
|
- Key: `test`
|
||||||
|
- Locale: `de-DE`
|
||||||
|
- Value: `Hallo von Directus!`
|
||||||
|
- Prüfe: `await getLocalizedMessage('test', 'de')` → "Hallo von Directus!"
|
||||||
|
|
||||||
|
2. **Ohne Directus:** Stoppe Directus
|
||||||
|
- Prüfe: Messages sollten aus JSON files kommen
|
||||||
|
- Website sollte normal funktionieren (degraded mode)
|
||||||
|
|
||||||
|
3. **Build Test:**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
- Sollte ohne Errors durchlaufen
|
||||||
|
|
||||||
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
|
### "Key nicht gefunden"
|
||||||
|
- Prüfe Directus GUI: Key exakt gleich? (`nav.home` nicht `nav_home`)
|
||||||
|
- Prüfe Locale: `de-DE` oder `en-US` (mit `-`)?
|
||||||
|
- Prüfe Permissions: Public role hat Read access?
|
||||||
|
|
||||||
|
### "Directus nicht erreichbar"
|
||||||
|
- Prüfe `DIRECTUS_URL` in .env
|
||||||
|
- Prüfe Token: `DIRECTUS_STATIC_TOKEN`
|
||||||
|
- Test: `curl -H "Authorization: Bearer TOKEN" https://cms.dk0.dev/items/messages`
|
||||||
|
|
||||||
|
### "Texte ändern sich nicht"
|
||||||
|
- Cache! Warte 5 Minuten oder restart Server
|
||||||
|
- Oder: Clear Cache manuell (`clearI18nCache()` in lib/i18n-loader.ts)
|
||||||
|
|
||||||
|
## 📚 Next Steps
|
||||||
|
|
||||||
|
1. **Directus deployen** (Docker auf IONOS)
|
||||||
|
2. **Collections erstellen** (messages, content_pages)
|
||||||
|
3. **Keys eintragen** (aus DIRECTUS_CHECKLIST.md)
|
||||||
|
4. **Testen** (dev environment)
|
||||||
|
5. **Production** (wenn alles funktioniert)
|
||||||
|
|
||||||
|
## 🎯 Benefits
|
||||||
|
|
||||||
|
- ✅ **Keine Rebuilds** für Text-Änderungen
|
||||||
|
- ✅ **Non-Tech Editor** kann Texte ändern (Directus GUI)
|
||||||
|
- ✅ **Graceful Degradation** (JSON Fallback)
|
||||||
|
- ✅ **Type Safety** (TypeScript Types für alle Translations)
|
||||||
|
- ✅ **Performance** (Server-side caching, parallel loading)
|
||||||
@@ -2,6 +2,16 @@ import { NextIntlClientProvider } from "next-intl";
|
|||||||
import { setRequestLocale } from "next-intl/server";
|
import { setRequestLocale } from "next-intl/server";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ConsentBanner from "../components/ConsentBanner";
|
import ConsentBanner from "../components/ConsentBanner";
|
||||||
|
import { getLocalizedMessage } from "@/lib/i18n-loader";
|
||||||
|
|
||||||
|
async function loadEnhancedMessages(locale: string) {
|
||||||
|
// Lade basis JSON Messages
|
||||||
|
const baseMessages = (await import(`../../messages/${locale}.json`)).default;
|
||||||
|
|
||||||
|
// Erweitere mit Directus (wenn verfügbar)
|
||||||
|
// Für jetzt: return base messages, Directus wird per Server Component geladen
|
||||||
|
return baseMessages;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
@@ -15,7 +25,7 @@ export default async function LocaleLayout({
|
|||||||
setRequestLocale(locale);
|
setRequestLocale(locale);
|
||||||
// Load messages explicitly by route locale to avoid falling back to the wrong
|
// Load messages explicitly by route locale to avoid falling back to the wrong
|
||||||
// language when request-level locale detection is unavailable/misconfigured.
|
// language when request-level locale detection is unavailable/misconfigured.
|
||||||
const messages = (await import(`../../messages/${locale}.json`)).default;
|
const messages = await loadEnhancedMessages(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import HomePage from "../_ui/HomePage";
|
import HomePageServer from "../_ui/HomePageServer";
|
||||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
@@ -17,7 +17,12 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default async function Page({
|
||||||
return <HomePage />;
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
return <HomePageServer locale={locale} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,20 +32,32 @@ export default async function ProjectPage({
|
|||||||
where: { slug, published: true },
|
where: { slug, published: true },
|
||||||
include: {
|
include: {
|
||||||
translations: {
|
translations: {
|
||||||
where: { locale },
|
select: { title: true, description: true, content: true, locale: true },
|
||||||
select: { title: true, description: true },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!project) return notFound();
|
if (!project) return notFound();
|
||||||
|
|
||||||
const tr = project.translations?.[0];
|
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 { 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;
|
||||||
|
}
|
||||||
|
return project.content;
|
||||||
|
})();
|
||||||
const localized = {
|
const localized = {
|
||||||
...rest,
|
...rest,
|
||||||
title: tr?.title ?? project.title,
|
title: tr?.title ?? project.title,
|
||||||
description: tr?.description ?? project.description,
|
description: tr?.description ?? project.description,
|
||||||
|
content: localizedContent,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ProjectDetailClient project={localized} locale={locale} />;
|
return <ProjectDetailClient project={localized} locale={locale} />;
|
||||||
|
|||||||
@@ -32,14 +32,17 @@ export default async function ProjectsPage({
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
translations: {
|
translations: {
|
||||||
where: { locale },
|
select: { title: true, description: true, locale: true },
|
||||||
select: { title: true, description: true },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const localized = projects.map((p) => {
|
const localized = projects.map((p) => {
|
||||||
const tr = p.translations?.[0];
|
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;
|
const { translations: _translations, ...rest } = p;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
|||||||
136
app/_ui/HomePageServer.tsx
Normal file
136
app/_ui/HomePageServer.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import Header from "../components/Header.server";
|
||||||
|
import Script from "next/script";
|
||||||
|
import ActivityFeedClient from "./ActivityFeedClient";
|
||||||
|
import {
|
||||||
|
getHeroTranslations,
|
||||||
|
getAboutTranslations,
|
||||||
|
getProjectsTranslations,
|
||||||
|
getContactTranslations,
|
||||||
|
getFooterTranslations,
|
||||||
|
} from "@/lib/translations-loader";
|
||||||
|
import {
|
||||||
|
HeroClient,
|
||||||
|
AboutClient,
|
||||||
|
ProjectsClient,
|
||||||
|
ContactClient,
|
||||||
|
FooterClient,
|
||||||
|
} from "../components/ClientWrappers";
|
||||||
|
|
||||||
|
interface HomePageServerProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePageServer({ locale }: HomePageServerProps) {
|
||||||
|
// Parallel laden aller Translations
|
||||||
|
const [heroT, aboutT, projectsT, contactT, footerT] = await Promise.all([
|
||||||
|
getHeroTranslations(locale),
|
||||||
|
getAboutTranslations(locale),
|
||||||
|
getProjectsTranslations(locale),
|
||||||
|
getContactTranslations(locale),
|
||||||
|
getFooterTranslations(locale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<Script
|
||||||
|
id={"structured-data"}
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Person",
|
||||||
|
name: "Dennis Konkol",
|
||||||
|
url: "https://dk0.dev",
|
||||||
|
jobTitle: "Software Engineer",
|
||||||
|
address: {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
addressLocality: "Osnabrück",
|
||||||
|
addressCountry: "Germany",
|
||||||
|
},
|
||||||
|
sameAs: [
|
||||||
|
"https://github.com/Denshooter",
|
||||||
|
"https://linkedin.com/in/dkonkol",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActivityFeedClient />
|
||||||
|
<Header locale={locale} />
|
||||||
|
{/* Spacer to prevent navbar overlap */}
|
||||||
|
<div className="h-24 md:h-32" aria-hidden="true"></div>
|
||||||
|
<main className="relative">
|
||||||
|
<HeroClient locale={locale} translations={heroT} />
|
||||||
|
|
||||||
|
{/* Wavy Separator 1 - Hero to About */}
|
||||||
|
<div className="relative h-24 overflow-hidden">
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
viewBox="0 0 1440 120"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0,64 C240,96 480,32 720,64 C960,96 1200,32 1440,64 L1440,120 L0,120 Z"
|
||||||
|
fill="url(#gradient1)"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient1" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#BAE6FD" stopOpacity="0.4" />
|
||||||
|
<stop offset="50%" stopColor="#DDD6FE" stopOpacity="0.4" />
|
||||||
|
<stop offset="100%" stopColor="#FBCFE8" stopOpacity="0.4" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AboutClient locale={locale} translations={aboutT} />
|
||||||
|
|
||||||
|
{/* Wavy Separator 2 - About to Projects */}
|
||||||
|
<div className="relative h-24 overflow-hidden">
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
viewBox="0 0 1440 120"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0,64 C360,96 720,32 1080,64 C1200,96 1320,32 1440,64 L1440,0 L0,0 Z"
|
||||||
|
fill="url(#gradient2)"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient2" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#A7F3D0" stopOpacity="0.3" />
|
||||||
|
<stop offset="50%" stopColor="#BFDBFE" stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor="#DDD6FE" stopOpacity="0.3" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectsClient locale={locale} translations={projectsT} />
|
||||||
|
|
||||||
|
{/* Wavy Separator 3 - Projects to Contact */}
|
||||||
|
<div className="relative h-24 overflow-hidden">
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
viewBox="0 0 1440 120"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0,32 C240,64 480,0 720,32 C960,64 1200,0 1440,32 L1440,120 L0,120 Z"
|
||||||
|
fill="url(#gradient3)"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient3" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor="#FDE68A" stopOpacity="0.3" />
|
||||||
|
<stop offset="50%" stopColor="#FCA5A5" stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor="#C4B5FD" stopOpacity="0.3" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactClient locale={locale} translations={contactT} />
|
||||||
|
</main>
|
||||||
|
<FooterClient locale={locale} translations={footerT} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { ExternalLink, Calendar, ArrowLeft, Github as GithubIcon, Share2 } from
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export type ProjectDetailData = {
|
export type ProjectDetailData = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -28,6 +29,10 @@ export default function ProjectDetailClient({
|
|||||||
project: ProjectDetailData;
|
project: ProjectDetailData;
|
||||||
locale: string;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const tDetail = useTranslations("projects.detail");
|
||||||
|
const tShared = useTranslations("projects.shared");
|
||||||
|
|
||||||
// Track page view (non-blocking)
|
// Track page view (non-blocking)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +69,7 @@ export default function ProjectDetailClient({
|
|||||||
className="inline-flex items-center space-x-2 text-stone-500 hover:text-stone-900 transition-colors group"
|
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" />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span className="font-medium">Back to Projects</span>
|
<span className="font-medium">{tCommon("backToProjects")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -82,7 +87,7 @@ export default function ProjectDetailClient({
|
|||||||
<div className="flex gap-2 shrink-0 pt-2">
|
<div className="flex gap-2 shrink-0 pt-2">
|
||||||
{project.featured && (
|
{project.featured && (
|
||||||
<span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
|
<span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
|
||||||
Featured
|
{tShared("featured")}
|
||||||
</span>
|
</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">
|
<span className="px-4 py-1.5 bg-white border border-stone-200 text-stone-600 text-xs font-medium rounded-full shadow-sm">
|
||||||
@@ -99,7 +104,7 @@ export default function ProjectDetailClient({
|
|||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Calendar size={18} />
|
<Calendar size={18} />
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{new Date(project.date).toLocaleDateString(undefined, {
|
{new Date(project.date).toLocaleDateString(locale || undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -183,7 +188,7 @@ export default function ProjectDetailClient({
|
|||||||
<div className="bg-white/50 backdrop-blur-xl border border-white/60 p-6 rounded-2xl shadow-sm sticky top-32">
|
<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">
|
<h3 className="font-bold text-stone-900 mb-4 flex items-center gap-2">
|
||||||
<Share2 size={18} />
|
<Share2 size={18} />
|
||||||
Project Links
|
{tDetail("links")}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{project.live && project.live.trim() && project.live !== "#" ? (
|
{project.live && project.live.trim() && project.live !== "#" ? (
|
||||||
@@ -193,12 +198,12 @@ export default function ProjectDetailClient({
|
|||||||
rel="noopener noreferrer"
|
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"
|
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>Live Demo</span>
|
<span>{tDetail("liveDemo")}</span>
|
||||||
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
|
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||||
</a>
|
</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">
|
<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">
|
||||||
Live demo not available
|
{tDetail("liveNotAvailable")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -209,14 +214,14 @@ export default function ProjectDetailClient({
|
|||||||
rel="noopener noreferrer"
|
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"
|
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>View Source</span>
|
<span>{tDetail("viewSource")}</span>
|
||||||
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
|
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 pt-6 border-t border-stone-100">
|
<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">Tech Stack</h4>
|
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-3">{tDetail("techStack")}</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.tags.map((tag) => (
|
{project.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
|
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export type ProjectListItem = {
|
export type ProjectListItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -27,7 +28,11 @@ export default function ProjectsPageClient({
|
|||||||
projects: ProjectListItem[];
|
projects: ProjectListItem[];
|
||||||
locale: string;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
const tCommon = useTranslations("common");
|
||||||
|
const tList = useTranslations("projects.list");
|
||||||
|
const tShared = useTranslations("projects.shared");
|
||||||
|
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("all");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -37,13 +42,13 @@ export default function ProjectsPageClient({
|
|||||||
|
|
||||||
const categories = useMemo(() => {
|
const categories = useMemo(() => {
|
||||||
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
|
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
|
||||||
return ["All", ...unique];
|
return ["all", ...unique];
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
let result = projects;
|
let result = projects;
|
||||||
|
|
||||||
if (selectedCategory !== "All") {
|
if (selectedCategory !== "all") {
|
||||||
result = result.filter((project) => project.category === selectedCategory);
|
result = result.filter((project) => project.category === selectedCategory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,16 +82,13 @@ export default function ProjectsPageClient({
|
|||||||
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 space-x-2 text-stone-500 hover:text-stone-800 transition-colors mb-8 group"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||||
<span>Back to Home</span>
|
<span>{tCommon("backToHome")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
||||||
My Projects
|
{tList("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">
|
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
|
||||||
Explore my portfolio of projects, from web applications to mobile apps. Each project showcases different
|
|
||||||
skills and technologies.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Filters & Search */}
|
{/* Filters & Search */}
|
||||||
@@ -108,7 +110,7 @@ export default function ProjectsPageClient({
|
|||||||
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
|
: "bg-white text-stone-600 border-stone-200 hover:bg-stone-50 hover:border-stone-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category}
|
{category === "all" ? tList("all") : category}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +120,7 @@ export default function ProjectsPageClient({
|
|||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-stone-400" size={18} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search projects..."
|
placeholder={tList("searchPlaceholder")}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
|
className="w-full 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"
|
||||||
@@ -172,7 +174,7 @@ export default function ProjectsPageClient({
|
|||||||
{project.featured && (
|
{project.featured && (
|
||||||
<div className="absolute top-3 left-3 z-20">
|
<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">
|
<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">
|
||||||
Featured
|
{tShared("featured")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -273,15 +275,15 @@ export default function ProjectsPageClient({
|
|||||||
|
|
||||||
{filteredProjects.length === 0 && (
|
{filteredProjects.length === 0 && (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
<p className="text-stone-500 text-lg">No projects found matching your criteria.</p>
|
<p className="text-stone-500 text-lg">{tList("noResults")}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCategory("All");
|
setSelectedCategory("all");
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
}}
|
}}
|
||||||
className="mt-4 text-stone-800 font-medium hover:underline"
|
className="mt-4 text-stone-800 font-medium hover:underline"
|
||||||
>
|
>
|
||||||
Clear filters
|
{tList("clearFilters")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
79
app/api/i18n/[namespace]/route.ts
Normal file
79
app/api/i18n/[namespace]/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
||||||
|
import enMessages from '@/messages/en.json';
|
||||||
|
import deMessages from '@/messages/de.json';
|
||||||
|
|
||||||
|
// Cache für 5 Minuten
|
||||||
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
const messagesMap = { en: enMessages, de: deMessages };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/i18n/[namespace]?locale=en
|
||||||
|
* Lädt alle Keys eines Namespace aus Directus oder JSON
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: { namespace: string } }
|
||||||
|
) {
|
||||||
|
const namespace = params.namespace;
|
||||||
|
const locale = req.nextUrl.searchParams.get('locale') || 'en';
|
||||||
|
|
||||||
|
// Normalize locale (de-DE -> de)
|
||||||
|
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Hole alle Keys aus JSON für diesen Namespace
|
||||||
|
const jsonData = messagesMap[normalizedLocale as 'en' | 'de'];
|
||||||
|
const namespaceData = getNestedValue(jsonData, namespace);
|
||||||
|
|
||||||
|
if (!namespaceData || typeof namespaceData !== 'object') {
|
||||||
|
return NextResponse.json({}, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten das Objekt zu flachen Keys
|
||||||
|
const flatKeys = flattenObject(namespaceData);
|
||||||
|
|
||||||
|
// Lade jeden Key aus Directus (mit Fallback auf JSON)
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(flatKeys).map(async ([key, jsonValue]) => {
|
||||||
|
const fullKey = `${namespace}.${key}`;
|
||||||
|
const value = await getLocalizedMessage(fullKey, locale);
|
||||||
|
result[key] = value || String(jsonValue);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('i18n API error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to load translations' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Holt verschachtelte Werte aus Objekt
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Flatten verschachteltes Objekt zu flachen Keys
|
||||||
|
function flattenObject(obj: any, 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));
|
||||||
|
} else {
|
||||||
|
result[newKey] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
94
app/api/messages/route.ts
Normal file
94
app/api/messages/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
||||||
|
import enMessages from '@/messages/en.json';
|
||||||
|
import deMessages from '@/messages/de.json';
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -42,11 +42,13 @@ export async function PUT(
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
content?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const locale = body.locale || "en";
|
const locale = body.locale || "en";
|
||||||
const title = body.title?.trim();
|
const title = body.title?.trim();
|
||||||
const description = body.description?.trim();
|
const description = body.description?.trim();
|
||||||
|
const content = typeof body.content === "string" ? body.content.trim() : undefined;
|
||||||
|
|
||||||
if (!title || !description) {
|
if (!title || !description) {
|
||||||
return NextResponse.json({ error: "title and description are required" }, { status: 400 });
|
return NextResponse.json({ error: "title and description are required" }, { status: 400 });
|
||||||
@@ -59,10 +61,12 @@ export async function PUT(
|
|||||||
locale,
|
locale,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
content: content ?? null,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
content: content ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
111
app/components/ClientWrappers.tsx
Normal file
111
app/components/ClientWrappers.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transitional Wrapper für bestehende Components
|
||||||
|
* Nutzt direkt JSON Messages statt komplexe Translation-Loader
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import Hero from './Hero';
|
||||||
|
import About from './About';
|
||||||
|
import Projects from './Projects';
|
||||||
|
import Contact from './Contact';
|
||||||
|
import Footer from './Footer';
|
||||||
|
import type {
|
||||||
|
HeroTranslations,
|
||||||
|
AboutTranslations,
|
||||||
|
ProjectsTranslations,
|
||||||
|
ContactTranslations,
|
||||||
|
FooterTranslations,
|
||||||
|
} from '@/types/translations';
|
||||||
|
import enMessages from '@/messages/en.json';
|
||||||
|
import deMessages from '@/messages/de.json';
|
||||||
|
|
||||||
|
const messageMap = { en: enMessages, de: deMessages };
|
||||||
|
|
||||||
|
function getNormalizedLocale(locale: string): 'en' | 'de' {
|
||||||
|
return locale.startsWith('de') ? 'de' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroClient({ locale, translations }: { locale: string; translations: HeroTranslations }) {
|
||||||
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
home: {
|
||||||
|
hero: baseMessages.home.hero
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<Hero />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AboutClient({ locale, translations }: { locale: string; translations: AboutTranslations }) {
|
||||||
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
home: {
|
||||||
|
about: baseMessages.home.about
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<About />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsClient({ locale, translations }: { locale: string; translations: ProjectsTranslations }) {
|
||||||
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
home: {
|
||||||
|
projects: baseMessages.home.projects
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<Projects />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactClient({ locale, translations }: { locale: string; translations: ContactTranslations }) {
|
||||||
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
home: {
|
||||||
|
contact: baseMessages.home.contact
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<Contact />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FooterClient({ locale, translations }: { locale: string; translations: FooterTranslations }) {
|
||||||
|
const normalLocale = getNormalizedLocale(locale);
|
||||||
|
const baseMessages = messageMap[normalLocale];
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
footer: baseMessages.footer
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
<Footer />
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
app/components/Header.server.tsx
Normal file
12
app/components/Header.server.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { getNavTranslations } from '@/lib/translations-loader';
|
||||||
|
import HeaderClient from './HeaderClient';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Header({ locale }: HeaderProps) {
|
||||||
|
const translations = await getNavTranslations(locale);
|
||||||
|
|
||||||
|
return <HeaderClient locale={locale} translations={translations} />;
|
||||||
|
}
|
||||||
249
app/components/HeaderClient.tsx
Normal file
249
app/components/HeaderClient.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Menu, X, Mail } from "lucide-react";
|
||||||
|
import { SiGithub, SiLinkedin } from "react-icons/si";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
|
import type { NavTranslations } from "@/types/translations";
|
||||||
|
|
||||||
|
interface HeaderClientProps {
|
||||||
|
locale: string;
|
||||||
|
translations: NavTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeaderClient({ locale, translations }: HeaderClientProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
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: translations.home, href: `/${locale}` },
|
||||||
|
{ name: translations.about, href: isHome ? "#about" : `/${locale}#about` },
|
||||||
|
{ name: translations.projects, href: isHome ? "#projects" : `/${locale}/projects` },
|
||||||
|
{ name: translations.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}`;
|
||||||
|
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||||
|
className={`backdrop-blur-xl transition-all duration-500 flex justify-between items-center ${
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="text-2xl font-black font-sans text-stone-900 tracking-tighter liquid-hover flex items-center"
|
||||||
|
>
|
||||||
|
dk<span className="text-red-500">0</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.name}
|
||||||
|
whileHover={{ y: -2 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="text-stone-700 hover:text-stone-900 font-medium transition-colors relative liquid-hover"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<div className="flex items-center space-x-2 ml-4 pl-4 border-l border-stone-300">
|
||||||
|
<Link
|
||||||
|
href={enHref}
|
||||||
|
className={`px-2 py-1 text-sm font-medium rounded transition-all ${
|
||||||
|
locale === "en"
|
||||||
|
? "bg-stone-900 text-white"
|
||||||
|
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={deHref}
|
||||||
|
className={`px-2 py-1 text-sm font-medium rounded transition-all ${
|
||||||
|
locale === "de"
|
||||||
|
? "bg-stone-900 text-white"
|
||||||
|
: "text-stone-600 hover:text-stone-900 hover:bg-stone-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05, rotate: 90 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="md:hidden p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700 transition-colors"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 bg-stone-900/50 backdrop-blur-sm z-40 md:hidden"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%", opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: "100%", opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||||
|
className="fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50 md:hidden overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}`}
|
||||||
|
className="text-2xl font-black text-stone-900"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
dk<span className="text-red-500">0</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-2 rounded-lg bg-stone-100 hover:bg-stone-200 text-stone-700"
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="block px-4 py-3 text-stone-700 hover:bg-stone-50 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Language Switcher Mobile */}
|
||||||
|
<div className="flex gap-2 mt-6 pt-6 border-t border-stone-200">
|
||||||
|
<Link
|
||||||
|
href={enHref}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
|
||||||
|
locale === "en"
|
||||||
|
? "bg-stone-900 text-white"
|
||||||
|
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={deHref}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={`flex-1 px-4 py-2 text-center font-medium rounded-lg transition-all ${
|
||||||
|
locale === "de"
|
||||||
|
? "bg-stone-900 text-white"
|
||||||
|
: "bg-stone-100 text-stone-600 hover:bg-stone-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-6 border-t border-stone-200">
|
||||||
|
<div className="flex justify-center space-x-6">
|
||||||
|
{socialLinks.map((link) => {
|
||||||
|
const Icon = link.icon;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={link.label}
|
||||||
|
href={link.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-3 rounded-lg bg-stone-100 hover:bg-stone-900 hover:text-white text-stone-700 transition-all"
|
||||||
|
aria-label={link.label}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ function EditorPageContent() {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isCreating, setIsCreating] = useState(!projectId);
|
const [isCreating, setIsCreating] = useState(!projectId);
|
||||||
const [editLocale, setEditLocale] = useState(initialLocale);
|
const [editLocale, setEditLocale] = useState(initialLocale);
|
||||||
const [baseTexts, setBaseTexts] = useState<{ title: string; description: string } | null>(null);
|
const [baseTexts, setBaseTexts] = useState<{ title: string; description: string; content: string } | null>(null);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [_isTyping, setIsTyping] = useState(false);
|
const [_isTyping, setIsTyping] = useState(false);
|
||||||
const [history, setHistory] = useState<typeof formData[]>([]);
|
const [history, setHistory] = useState<typeof formData[]>([]);
|
||||||
@@ -96,6 +96,7 @@ function EditorPageContent() {
|
|||||||
setBaseTexts({
|
setBaseTexts({
|
||||||
title: foundProject.title || "",
|
title: foundProject.title || "",
|
||||||
description: foundProject.description || "",
|
description: foundProject.description || "",
|
||||||
|
content: foundProject.content || "",
|
||||||
});
|
});
|
||||||
const initialData = {
|
const initialData = {
|
||||||
title: foundProject.title || "",
|
title: foundProject.title || "",
|
||||||
@@ -145,19 +146,64 @@ function EditorPageContent() {
|
|||||||
});
|
});
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const tr = data.translation as { title?: string; description?: string } | null;
|
const tr = data.translation as { title?: string; description?: string; content?: unknown } | null;
|
||||||
if (tr?.title && tr?.description) {
|
const translatedContent = (() => {
|
||||||
setFormData((prev) => ({
|
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 null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (tr?.title || tr?.description || translatedContent !== null) {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const next = {
|
||||||
...prev,
|
...prev,
|
||||||
title: tr.title || prev.title,
|
title: tr?.title || prev.title,
|
||||||
description: tr.description || prev.description,
|
description: tr?.description || prev.description,
|
||||||
}));
|
content: translatedContent ?? prev.content,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (translatedContent !== null) {
|
||||||
|
shouldUpdateContentRef.current = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore translation load failures
|
// ignore translation load failures
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const switchLocale = useCallback(
|
||||||
|
(next: string) => {
|
||||||
|
setEditLocale(next);
|
||||||
|
if (projectId) {
|
||||||
|
const newUrl = `/editor?id=${projectId}&locale=${encodeURIComponent(next)}`;
|
||||||
|
window.history.replaceState({}, "", newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next === "en" && baseTexts) {
|
||||||
|
setFormData((prev) => {
|
||||||
|
const nextData = {
|
||||||
|
...prev,
|
||||||
|
title: baseTexts.title,
|
||||||
|
description: baseTexts.description,
|
||||||
|
content: baseTexts.content,
|
||||||
|
};
|
||||||
|
return nextData;
|
||||||
|
});
|
||||||
|
shouldUpdateContentRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
loadTranslation(projectId, next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId, baseTexts, loadTranslation],
|
||||||
|
);
|
||||||
|
|
||||||
// Check authentication and load project
|
// Check authentication and load project
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -188,6 +234,7 @@ function EditorPageContent() {
|
|||||||
live: "",
|
live: "",
|
||||||
image: "",
|
image: "",
|
||||||
};
|
};
|
||||||
|
setBaseTexts({ title: "", description: "", content: "" });
|
||||||
setFormData(initialData);
|
setFormData(initialData);
|
||||||
setOriginalFormData(initialData);
|
setOriginalFormData(initialData);
|
||||||
setHistory([initialData]);
|
setHistory([initialData]);
|
||||||
@@ -240,11 +287,12 @@ function EditorPageContent() {
|
|||||||
const saveTitle = editLocale === "en" ? formData.title.trim() : (baseTexts?.title || formData.title.trim());
|
const saveTitle = editLocale === "en" ? formData.title.trim() : (baseTexts?.title || formData.title.trim());
|
||||||
const saveDescription =
|
const saveDescription =
|
||||||
editLocale === "en" ? formData.description.trim() : (baseTexts?.description || formData.description.trim());
|
editLocale === "en" ? formData.description.trim() : (baseTexts?.description || formData.description.trim());
|
||||||
|
const saveContent = editLocale === "en" ? formData.content.trim() : (baseTexts?.content || formData.content.trim());
|
||||||
|
|
||||||
const saveData = {
|
const saveData = {
|
||||||
title: saveTitle,
|
title: saveTitle,
|
||||||
description: saveDescription,
|
description: saveDescription,
|
||||||
content: formData.content.trim(),
|
content: saveContent,
|
||||||
category: formData.category,
|
category: formData.category,
|
||||||
tags: formData.tags,
|
tags: formData.tags,
|
||||||
github: formData.github.trim() || null,
|
github: formData.github.trim() || null,
|
||||||
@@ -302,6 +350,7 @@ function EditorPageContent() {
|
|||||||
locale: editLocale,
|
locale: editLocale,
|
||||||
title: formData.title.trim(),
|
title: formData.title.trim(),
|
||||||
description: formData.description.trim(),
|
description: formData.description.trim(),
|
||||||
|
content: formData.content.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -309,6 +358,14 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editLocale === "en") {
|
||||||
|
setBaseTexts({
|
||||||
|
title: savedProject.title || "",
|
||||||
|
description: savedProject.description || "",
|
||||||
|
content: savedProject.content || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update project ID if it was a new project
|
// Update project ID if it was a new project
|
||||||
if (!projectId && savedProject.id) {
|
if (!projectId && savedProject.id) {
|
||||||
const newUrl = `/editor?id=${savedProject.id}`;
|
const newUrl = `/editor?id=${savedProject.id}`;
|
||||||
@@ -706,27 +763,40 @@ function EditorPageContent() {
|
|||||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||||
Language
|
Language
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="custom-select">
|
<div className="custom-select">
|
||||||
<select
|
<select
|
||||||
value={editLocale}
|
value={editLocale}
|
||||||
onChange={(e) => {
|
onChange={(e) => switchLocale(e.target.value)}
|
||||||
const next = e.target.value;
|
|
||||||
setEditLocale(next);
|
|
||||||
if (projectId) {
|
|
||||||
// Update URL for deep-linking and reload translation
|
|
||||||
const newUrl = `/editor?id=${projectId}&locale=${encodeURIComponent(next)}`;
|
|
||||||
window.history.replaceState({}, "", newUrl);
|
|
||||||
loadTranslation(projectId, next);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="en">English (default)</option>
|
<option value="en">English (default)</option>
|
||||||
<option value="de">Deutsch</option>
|
<option value="de">Deutsch</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-stone-700/40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => switchLocale("en")}
|
||||||
|
className={`px-3 py-1 text-sm ${
|
||||||
|
editLocale === "en" ? "bg-stone-700 text-white" : "bg-stone-800 text-stone-300 hover:bg-stone-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => switchLocale("de")}
|
||||||
|
className={`px-3 py-1 text-sm ${
|
||||||
|
editLocale === "de" ? "bg-stone-700 text-white" : "bg-stone-800 text-stone-300 hover:bg-stone-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
DE
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{editLocale !== "en" && (
|
{editLocale !== "en" && (
|
||||||
<p className="text-xs text-stone-400 mt-2">
|
<p className="text-xs text-stone-400 mt-2">
|
||||||
Title/description are saved as a translation. Other fields are global.
|
Title, description, and content are saved as a translation. Other fields are global.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
59
components/I18nWrapper.tsx
Normal file
59
components/I18nWrapper.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Server Component für i18n-Texte
|
||||||
|
* Nutzt Directus mit Fallback auf next-intl/JSON
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getLocalizedMessage, getLocalizedContent } from '@/lib/i18n-loader';
|
||||||
|
|
||||||
|
interface I18nTextProps {
|
||||||
|
msgKey: string;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
fallback?: string; // Falls Key nicht in Directus AND nicht in JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen kurzen, lokalisierten Text.
|
||||||
|
* Directus > next-intl/JSON > Fallback > Key selbst.
|
||||||
|
*/
|
||||||
|
export async function I18nText({
|
||||||
|
msgKey,
|
||||||
|
locale,
|
||||||
|
fallback,
|
||||||
|
}: I18nTextProps) {
|
||||||
|
const text = await getLocalizedMessage(msgKey, locale);
|
||||||
|
return <>{text || fallback || msgKey}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nContentProps {
|
||||||
|
slug: string;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt ein längeres, lokalisiertes Inhaltsblöck.
|
||||||
|
* Nur Directus, kein JSON-Fallback.
|
||||||
|
*/
|
||||||
|
export async function I18nContent({
|
||||||
|
slug,
|
||||||
|
locale,
|
||||||
|
fallback,
|
||||||
|
}: I18nContentProps) {
|
||||||
|
const page = await getLocalizedContent(slug, locale);
|
||||||
|
|
||||||
|
if (!page?.content) {
|
||||||
|
return <>{fallback || null}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn content ein String ist (Markdown/Plain Text):
|
||||||
|
if (typeof page.content === 'string') {
|
||||||
|
return <div className="prose prose-stone max-w-none">{page.content}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn content ein JSON-Objekt ist (Rich Text Editor):
|
||||||
|
return (
|
||||||
|
<div className="prose prose-stone max-w-none">
|
||||||
|
{JSON.stringify(page.content)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:16-alpine
|
||||||
container_name: portfolio_postgres_dev
|
container_name: portfolio_postgres_dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: portfolio_dev
|
POSTGRES_DB: portfolio_dev
|
||||||
POSTGRES_USER: portfolio_user
|
POSTGRES_USER: portfolio_user
|
||||||
@@ -24,6 +26,8 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: portfolio_redis_dev
|
container_name: portfolio_redis_dev
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_dev_data:/data
|
- redis_dev_data:/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
|||||||
N8N_SECRET_TOKEN=your-n8n-secret-token
|
N8N_SECRET_TOKEN=your-n8n-secret-token
|
||||||
N8N_API_KEY=your-n8n-api-key
|
N8N_API_KEY=your-n8n-api-key
|
||||||
|
|
||||||
|
# Directus CMS (for i18n messages & content pages)
|
||||||
|
DIRECTUS_URL=https://cms.dk0.dev
|
||||||
|
DIRECTUS_STATIC_TOKEN=your-static-token-here
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
# JWT_SECRET=your-jwt-secret
|
# JWT_SECRET=your-jwt-secret
|
||||||
# ENCRYPTION_KEY=your-encryption-key
|
# ENCRYPTION_KEY=your-encryption-key
|
||||||
|
|||||||
37
hooks/useDirectusTranslations.tsx
Normal file
37
hooks/useDirectusTranslations.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side Hook für Directus-Translations
|
||||||
|
* Fetcht Texte über API Route statt direkt
|
||||||
|
*/
|
||||||
|
export function useDirectusTranslations(namespace: string) {
|
||||||
|
const locale = useLocale();
|
||||||
|
const [translations, setTranslations] = useState<Record<string, string>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadTranslations() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/i18n/${namespace}?locale=${locale}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setTranslations(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load translations:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTranslations();
|
||||||
|
}, [namespace, locale]);
|
||||||
|
|
||||||
|
return (key: string) => {
|
||||||
|
if (loading) return '...';
|
||||||
|
return translations[key] || key;
|
||||||
|
};
|
||||||
|
}
|
||||||
151
lib/directus.ts
Normal file
151
lib/directus.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Directus API Client (REST-based, no SDK dependencies)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||||
|
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN || '';
|
||||||
|
|
||||||
|
// Mapping: next-intl locale → Directus language code
|
||||||
|
const localeToDirectus: Record<string, string> = {
|
||||||
|
en: 'en-US',
|
||||||
|
de: 'de-DE',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDirectusLocale(locale: string): string {
|
||||||
|
return localeToDirectus[locale] || locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
|
body?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function directusRequest<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: FetchOptions = {}
|
||||||
|
): Promise<T | null> {
|
||||||
|
// Wenn kein Token gesetzt, skip Directus (nutze JSON fallback)
|
||||||
|
if (!DIRECTUS_TOKEN || DIRECTUS_TOKEN === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${DIRECTUS_URL}/graphql`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(options.body || {}),
|
||||||
|
// Timeout nach 2 Sekunden
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Collection noch nicht erstellt? Stille fallback zu JSON
|
||||||
|
const text = await response.text();
|
||||||
|
if (text.includes('GRAPHQL_VALIDATION') || text.includes('Cannot query field')) {
|
||||||
|
// Stille: Collection existiert noch nicht
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.error(`Directus error: ${response.status}`, text);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Prüfe auf GraphQL errors
|
||||||
|
if (data?.errors) {
|
||||||
|
// Stille: Collection noch nicht ready
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data?.data || null;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Timeout oder Network Error - stille fallback
|
||||||
|
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Andere Errors nur in dev loggen
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('Directus request failed:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessage(key: string, locale: string): Promise<string | null> {
|
||||||
|
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) {
|
||||||
|
key
|
||||||
|
translations {
|
||||||
|
value
|
||||||
|
languages_code {
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages = (result as any)?.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
|
||||||
|
);
|
||||||
|
|
||||||
|
return translation?.value || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch message ${key} (${locale}):`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContentPage(
|
||||||
|
slug: string,
|
||||||
|
locale: string
|
||||||
|
): Promise<any | null> {
|
||||||
|
const directusLocale = toDirectusLocale(locale);
|
||||||
|
const query = `
|
||||||
|
query {
|
||||||
|
content_pages(filter: {slug: {_eq: "${slug}"}, locale: {_eq: "${directusLocale}"}}, limit: 1) {
|
||||||
|
id
|
||||||
|
slug
|
||||||
|
locale
|
||||||
|
title
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await directusRequest(
|
||||||
|
'',
|
||||||
|
{ body: { query } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const pages = (result as any)?.content_pages;
|
||||||
|
return pages?.[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch content page ${slug} (${locale}):`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
133
lib/i18n-loader.ts
Normal file
133
lib/i18n-loader.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* i18n Loader with Directus + JSON Fallback
|
||||||
|
* - Fetches from Directus first
|
||||||
|
* - Falls back to JSON files if not found
|
||||||
|
* - Caches results (5 min TTL)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getMessage, getContentPage } 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 }>();
|
||||||
|
|
||||||
|
function setCached(key: string, value: any, ttlSeconds = 300) {
|
||||||
|
cache.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCached(key: string): any | null {
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() > hit.expires) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hit.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a localized message by key
|
||||||
|
* Tries: Directus (requested locale) → Directus (EN) → JSON (requested locale) → JSON (EN)
|
||||||
|
*/
|
||||||
|
export async function getLocalizedMessage(
|
||||||
|
key: string,
|
||||||
|
locale: string
|
||||||
|
): Promise<string> {
|
||||||
|
const cacheKey = `msg:${key}:${locale}`;
|
||||||
|
const cached = getCached(cacheKey);
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
|
||||||
|
// Try Directus with requested locale
|
||||||
|
const dbValue = await getMessage(key, locale);
|
||||||
|
if (dbValue) {
|
||||||
|
setCached(cacheKey, dbValue);
|
||||||
|
return dbValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to EN in Directus if not EN already
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const dbValueEn = await getMessage(key, 'en');
|
||||||
|
if (dbValueEn) {
|
||||||
|
setCached(cacheKey, dbValueEn);
|
||||||
|
return dbValueEn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to JSON file (normalize locale to 'en' or 'de')
|
||||||
|
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
||||||
|
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
|
||||||
|
if (jsonValue) {
|
||||||
|
setCached(cacheKey, jsonValue);
|
||||||
|
return jsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to EN JSON
|
||||||
|
if (normalizedLocale !== 'en') {
|
||||||
|
const jsonValueEn = getNestedValue(jsonFallback['en'], key);
|
||||||
|
if (jsonValueEn) {
|
||||||
|
setCached(cacheKey, jsonValueEn);
|
||||||
|
return jsonValueEn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return the key itself
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a localized content page by slug
|
||||||
|
* Tries: Directus (requested locale) → Directus (EN)
|
||||||
|
*/
|
||||||
|
export async function getLocalizedContent(
|
||||||
|
slug: string,
|
||||||
|
locale: string
|
||||||
|
): Promise<any | 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
|
||||||
|
|
||||||
|
// Try Directus with requested locale
|
||||||
|
const dbPage = await getContentPage(slug, locale);
|
||||||
|
if (dbPage) {
|
||||||
|
setCached(cacheKey, dbPage);
|
||||||
|
return dbPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to EN in Directus
|
||||||
|
if (locale !== 'en') {
|
||||||
|
const dbPageEn = await getContentPage(slug, 'en');
|
||||||
|
if (dbPageEn) {
|
||||||
|
setCached(cacheKey, dbPageEn);
|
||||||
|
return dbPageEn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
setCached(cacheKey, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get nested value from object
|
||||||
|
* Example: "nav.home" → obj.nav.home
|
||||||
|
*/
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let value = obj;
|
||||||
|
for (const key of keys) {
|
||||||
|
value = value?.[key];
|
||||||
|
if (value === undefined) return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache (useful for webhooks/revalidation)
|
||||||
|
*/
|
||||||
|
export function clearI18nCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
206
lib/translations-loader.ts
Normal file
206
lib/translations-loader.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
||||||
|
import type {
|
||||||
|
NavTranslations,
|
||||||
|
FooterTranslations,
|
||||||
|
HeroTranslations,
|
||||||
|
AboutTranslations,
|
||||||
|
ProjectsTranslations,
|
||||||
|
ContactTranslations,
|
||||||
|
ConsentTranslations,
|
||||||
|
} from '@/types/translations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt alle Translations für eine Section aus Directus
|
||||||
|
* Nutzt optimierte Batch-Abfragen wo möglich
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function getNavTranslations(locale: string): Promise<NavTranslations> {
|
||||||
|
const [home, about, projects, contact] = await Promise.all([
|
||||||
|
getLocalizedMessage('nav.home', locale),
|
||||||
|
getLocalizedMessage('nav.about', locale),
|
||||||
|
getLocalizedMessage('nav.projects', locale),
|
||||||
|
getLocalizedMessage('nav.contact', locale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { home, about, projects, contact };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFooterTranslations(locale: string): Promise<FooterTranslations> {
|
||||||
|
const [role, description, privacy, imprint, copyright, madeWith, resetConsent] = await Promise.all([
|
||||||
|
getLocalizedMessage('footer.role', locale),
|
||||||
|
getLocalizedMessage('footer.description', locale),
|
||||||
|
getLocalizedMessage('footer.links.privacy', locale),
|
||||||
|
getLocalizedMessage('footer.links.imprint', locale),
|
||||||
|
getLocalizedMessage('footer.copyright', locale),
|
||||||
|
getLocalizedMessage('footer.madeWith', locale),
|
||||||
|
getLocalizedMessage('footer.resetConsent', locale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
description,
|
||||||
|
links: { privacy, imprint },
|
||||||
|
copyright,
|
||||||
|
madeWith,
|
||||||
|
resetConsent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHeroTranslations(locale: string): Promise<HeroTranslations> {
|
||||||
|
const keys = [
|
||||||
|
'home.hero.greeting',
|
||||||
|
'home.hero.name',
|
||||||
|
'home.hero.role',
|
||||||
|
'home.hero.description',
|
||||||
|
'home.hero.ctaWork',
|
||||||
|
'home.hero.ctaContact',
|
||||||
|
'home.hero.features.f1',
|
||||||
|
'home.hero.features.f2',
|
||||||
|
'home.hero.features.f3',
|
||||||
|
'home.hero.scrollDown',
|
||||||
|
];
|
||||||
|
|
||||||
|
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
greeting: values[0],
|
||||||
|
name: values[1],
|
||||||
|
role: values[2],
|
||||||
|
description: values[3],
|
||||||
|
cta: {
|
||||||
|
projects: values[4],
|
||||||
|
contact: values[5],
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
f1: values[6],
|
||||||
|
f2: values[7],
|
||||||
|
f3: values[8],
|
||||||
|
},
|
||||||
|
scrollDown: values[9],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAboutTranslations(locale: string): Promise<AboutTranslations> {
|
||||||
|
// Diese Keys sind NICHT korrekt - wir nutzen nur für Type Compatibility
|
||||||
|
// Die About Component nutzt actually: title, p1, p2, p3, hobbiesTitle, hobbies.*, techStackTitle, techStack.*
|
||||||
|
// Lade alle benötigten Keys
|
||||||
|
const keys = [
|
||||||
|
'home.about.title',
|
||||||
|
'home.about.description',
|
||||||
|
'home.about.techStack.title',
|
||||||
|
'home.about.techStack.categories.frontendMobile',
|
||||||
|
'home.about.techStack.categories.backendDevops',
|
||||||
|
'home.about.techStack.categories.toolsAutomation',
|
||||||
|
'home.about.techStack.categories.securityAdmin',
|
||||||
|
'home.about.techStack.items.selfHostedServices',
|
||||||
|
'home.about.hobbiesTitle', // Nicht "interests.title"!
|
||||||
|
'home.about.hobbies.selfHosting',
|
||||||
|
'home.about.hobbies.gaming',
|
||||||
|
'home.about.hobbies.gameServers',
|
||||||
|
'home.about.hobbies.jogging',
|
||||||
|
'home.about.p1',
|
||||||
|
'home.about.p2',
|
||||||
|
'home.about.p3',
|
||||||
|
'home.about.funFactTitle',
|
||||||
|
'home.about.funFactBody',
|
||||||
|
'home.about.techStackTitle',
|
||||||
|
];
|
||||||
|
|
||||||
|
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: values[0],
|
||||||
|
description: values[1],
|
||||||
|
techStack: {
|
||||||
|
title: values[2],
|
||||||
|
categories: {
|
||||||
|
frontendMobile: values[3],
|
||||||
|
backendDevops: values[4],
|
||||||
|
toolsAutomation: values[5],
|
||||||
|
securityAdmin: values[6],
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
selfHostedServices: values[7],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interests: {
|
||||||
|
title: values[8], // hobbiesTitle
|
||||||
|
cybersecurity: {
|
||||||
|
title: values[9], // hobbies.selfHosting
|
||||||
|
description: values[10], // hobbies.gaming
|
||||||
|
},
|
||||||
|
selfHosting: {
|
||||||
|
title: values[11], // hobbies.gameServers
|
||||||
|
description: values[12], // hobbies.jogging
|
||||||
|
},
|
||||||
|
gaming: {
|
||||||
|
title: values[13], // p1
|
||||||
|
description: values[14], // p2
|
||||||
|
},
|
||||||
|
automation: {
|
||||||
|
title: values[15], // p3
|
||||||
|
description: values[16], // funFactTitle
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectsTranslations(locale: string): Promise<ProjectsTranslations> {
|
||||||
|
const [title, viewAll] = await Promise.all([
|
||||||
|
getLocalizedMessage('home.projects.title', locale),
|
||||||
|
getLocalizedMessage('home.projects.viewAll', locale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { title, viewAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
|
||||||
|
const keys = [
|
||||||
|
'home.contact.title',
|
||||||
|
'home.contact.description',
|
||||||
|
'home.contact.form.name',
|
||||||
|
'home.contact.form.email',
|
||||||
|
'home.contact.form.message',
|
||||||
|
'home.contact.form.send',
|
||||||
|
'home.contact.form.sending',
|
||||||
|
'home.contact.form.success',
|
||||||
|
'home.contact.form.error',
|
||||||
|
'home.contact.info.title',
|
||||||
|
'home.contact.info.email',
|
||||||
|
'home.contact.info.response',
|
||||||
|
'home.contact.info.emailLabel',
|
||||||
|
];
|
||||||
|
|
||||||
|
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: values[0],
|
||||||
|
description: values[1],
|
||||||
|
form: {
|
||||||
|
name: values[2],
|
||||||
|
email: values[3],
|
||||||
|
message: values[4],
|
||||||
|
send: values[5],
|
||||||
|
sending: values[6],
|
||||||
|
success: values[7],
|
||||||
|
error: values[8],
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
title: values[9],
|
||||||
|
email: values[10],
|
||||||
|
response: values[11],
|
||||||
|
emailLabel: values[12],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConsentTranslations(locale: string): Promise<ConsentTranslations> {
|
||||||
|
const [title, description, accept, decline] = await Promise.all([
|
||||||
|
getLocalizedMessage('consent.title', locale),
|
||||||
|
getLocalizedMessage('consent.description', locale),
|
||||||
|
getLocalizedMessage('consent.accept', locale),
|
||||||
|
getLocalizedMessage('consent.decline', locale),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { title, description, accept, decline };
|
||||||
|
}
|
||||||
@@ -22,8 +22,7 @@
|
|||||||
"acceptSelected": "Auswahl akzeptieren",
|
"acceptSelected": "Auswahl akzeptieren",
|
||||||
"rejectAll": "Alles ablehnen",
|
"rejectAll": "Alles ablehnen",
|
||||||
"hide": "Ausblenden"
|
"hide": "Ausblenden"
|
||||||
}
|
},
|
||||||
,
|
|
||||||
"home": {
|
"home": {
|
||||||
"hero": {
|
"hero": {
|
||||||
"features": {
|
"features": {
|
||||||
@@ -59,7 +58,7 @@
|
|||||||
"selfHosting": "Self-Hosting & DevOps",
|
"selfHosting": "Self-Hosting & DevOps",
|
||||||
"gaming": "Gaming",
|
"gaming": "Gaming",
|
||||||
"gameServers": "Game-Server einrichten",
|
"gameServers": "Game-Server einrichten",
|
||||||
"jogging": "Joggen zum Kopf freibekommen und aktiv bleiben"
|
"jogging": "Joggen la Kopf freibekommen und aktiv bleiben"
|
||||||
},
|
},
|
||||||
"currentlyReading": {
|
"currentlyReading": {
|
||||||
"title": "Aktuell am Lesen",
|
"title": "Aktuell am Lesen",
|
||||||
@@ -112,8 +111,27 @@
|
|||||||
"characters": "{count} Zeichen"
|
"characters": "{count} Zeichen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"shared": {
|
||||||
|
"featured": "Hervorgehoben"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Meine Projekte",
|
||||||
|
"intro": "Stöbere durch mein Portfolio – von Web-Anwendungen bis Mobile Apps. Jedes Projekt zeigt unterschiedliche Skills und Technologien.",
|
||||||
|
"searchPlaceholder": "Projekte durchsuchen...",
|
||||||
|
"all": "Alle",
|
||||||
|
"noResults": "Keine Projekte passen zu deinen Filtern.",
|
||||||
|
"clearFilters": "Filter zurücksetzen"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"links": "Projektlinks",
|
||||||
|
"liveDemo": "Live-Demo",
|
||||||
|
"liveNotAvailable": "Keine Live-Demo verfügbar",
|
||||||
|
"viewSource": "Quellcode ansehen",
|
||||||
|
"techStack": "Tech-Stack"
|
||||||
}
|
}
|
||||||
,
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"role": "Software Engineer",
|
"role": "Software Engineer",
|
||||||
"madeIn": "Made in Germany",
|
"madeIn": "Made in Germany",
|
||||||
@@ -124,4 +142,3 @@
|
|||||||
"builtWith": "Built with"
|
"builtWith": "Built with"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
,
|
,
|
||||||
|
"projects": {
|
||||||
|
"shared": {
|
||||||
|
"featured": "Featured"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "My Projects",
|
||||||
|
"intro": "Explore my portfolio of projects, from web applications to mobile apps. Each project showcases different skills and technologies.",
|
||||||
|
"searchPlaceholder": "Search projects...",
|
||||||
|
"all": "All",
|
||||||
|
"noResults": "No projects found matching your criteria.",
|
||||||
|
"clearFilters": "Clear filters"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"links": "Project Links",
|
||||||
|
"liveDemo": "Live Demo",
|
||||||
|
"liveNotAvailable": "Live demo not available",
|
||||||
|
"viewSource": "View Source",
|
||||||
|
"techStack": "Tech Stack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
,
|
||||||
"footer": {
|
"footer": {
|
||||||
"role": "Software Engineer",
|
"role": "Software Engineer",
|
||||||
"madeIn": "Made in Germany",
|
"madeIn": "Made in Germany",
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
// NOTE: `optimizePackageImports` can cause dev-time webpack runtime issues with some setups.
|
|
||||||
// Keep it enabled for production builds only.
|
|
||||||
experimental:
|
experimental:
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? {
|
? {
|
||||||
optimizePackageImports: ["lucide-react", "framer-motion"],
|
optimizePackageImports: ["lucide-react", "framer-motion"],
|
||||||
}
|
}
|
||||||
: {},
|
: {
|
||||||
|
// In development, enable webpack build worker for faster builds
|
||||||
|
webpackBuildWorker: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Image optimization
|
// Image optimization
|
||||||
images: {
|
images: {
|
||||||
@@ -63,7 +64,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Webpack configuration
|
// Webpack configuration
|
||||||
webpack: (config) => {
|
webpack: (config, { dev, isServer }) => {
|
||||||
// Fix for module resolution issues
|
// Fix for module resolution issues
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback,
|
...config.resolve.fallback,
|
||||||
@@ -72,6 +73,27 @@ const nextConfig: NextConfig = {
|
|||||||
tls: false,
|
tls: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optimize webpack cache - fix "Serializing big strings" warnings in dev by avoiding FS cache
|
||||||
|
if (dev) {
|
||||||
|
config.cache = {
|
||||||
|
type: "memory",
|
||||||
|
maxGenerations: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isServer) {
|
||||||
|
// Optimize module concatenation and chunking for the client build
|
||||||
|
config.optimization = {
|
||||||
|
...config.optimization,
|
||||||
|
moduleIds: "deterministic",
|
||||||
|
chunkIds: "deterministic",
|
||||||
|
splitChunks: {
|
||||||
|
...config.optimization?.splitChunks,
|
||||||
|
maxSize: 200000, // keep chunks <200KB to avoid large-string serialization
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ exec('docker-compose --version', (error) => {
|
|||||||
"postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public",
|
"postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public",
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: "redis://localhost:6379",
|
||||||
NODE_ENV: "development",
|
NODE_ENV: "development",
|
||||||
|
// Suppress Node.js deprecation warnings (they're from dependencies)
|
||||||
|
NODE_NO_WARNINGS: "1",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure DB schema exists before starting Next dev server.
|
// Ensure DB schema exists before starting Next dev server.
|
||||||
|
|||||||
108
types/translations.ts
Normal file
108
types/translations.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Type Definitions für Directus-basierte Translations
|
||||||
|
* Jede Section hat ihre eigenen Translation Props
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NavTranslations {
|
||||||
|
home: string;
|
||||||
|
about: string;
|
||||||
|
projects: string;
|
||||||
|
contact: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterTranslations {
|
||||||
|
role: string;
|
||||||
|
description: string;
|
||||||
|
links: {
|
||||||
|
privacy: string;
|
||||||
|
imprint: string;
|
||||||
|
};
|
||||||
|
copyright: string;
|
||||||
|
madeWith: string;
|
||||||
|
resetConsent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeroTranslations {
|
||||||
|
greeting: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
description: string;
|
||||||
|
cta: {
|
||||||
|
projects: string;
|
||||||
|
contact: string;
|
||||||
|
};
|
||||||
|
features: {
|
||||||
|
f1: string;
|
||||||
|
f2: string;
|
||||||
|
f3: string;
|
||||||
|
};
|
||||||
|
scrollDown: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AboutTranslations {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
techStack: {
|
||||||
|
title: string;
|
||||||
|
categories: {
|
||||||
|
frontendMobile: string;
|
||||||
|
backendDevops: string;
|
||||||
|
toolsAutomation: string;
|
||||||
|
securityAdmin: string;
|
||||||
|
};
|
||||||
|
items: {
|
||||||
|
selfHostedServices: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
interests: {
|
||||||
|
title: string;
|
||||||
|
cybersecurity: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
selfHosting: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
gaming: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
automation: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectsTranslations {
|
||||||
|
title: string;
|
||||||
|
viewAll: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactTranslations {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
form: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
send: string;
|
||||||
|
sending: string;
|
||||||
|
success: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
info: {
|
||||||
|
title: string;
|
||||||
|
email: string;
|
||||||
|
response: string;
|
||||||
|
emailLabel: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentTranslations {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
accept: string;
|
||||||
|
decline: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user