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 React from "react";
|
||||
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({
|
||||
children,
|
||||
@@ -15,7 +25,7 @@ export default async function LocaleLayout({
|
||||
setRequestLocale(locale);
|
||||
// Load messages explicitly by route locale to avoid falling back to the wrong
|
||||
// language when request-level locale detection is unavailable/misconfigured.
|
||||
const messages = (await import(`../../messages/${locale}.json`)).default;
|
||||
const messages = await loadEnhancedMessages(locale);
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import HomePage from "../_ui/HomePage";
|
||||
import HomePageServer from "../_ui/HomePageServer";
|
||||
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||
|
||||
export async function generateMetadata({
|
||||
@@ -17,7 +17,12 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return <HomePage />;
|
||||
export default async function Page({
|
||||
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 },
|
||||
include: {
|
||||
translations: {
|
||||
where: { locale },
|
||||
select: { title: true, description: true },
|
||||
select: { title: true, description: true, content: true, locale: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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 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 = {
|
||||
...rest,
|
||||
title: tr?.title ?? project.title,
|
||||
description: tr?.description ?? project.description,
|
||||
content: localizedContent,
|
||||
};
|
||||
|
||||
return <ProjectDetailClient project={localized} locale={locale} />;
|
||||
|
||||
@@ -32,14 +32,17 @@ export default async function ProjectsPage({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
translations: {
|
||||
where: { locale },
|
||||
select: { title: true, description: true },
|
||||
select: { title: true, description: true, locale: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
return {
|
||||
...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 { useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ProjectDetailData = {
|
||||
id: number;
|
||||
@@ -28,6 +29,10 @@ export default function ProjectDetailClient({
|
||||
project: ProjectDetailData;
|
||||
locale: string;
|
||||
}) {
|
||||
const tCommon = useTranslations("common");
|
||||
const tDetail = useTranslations("projects.detail");
|
||||
const tShared = useTranslations("projects.shared");
|
||||
|
||||
// Track page view (non-blocking)
|
||||
useEffect(() => {
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
@@ -82,7 +87,7 @@ export default function ProjectDetailClient({
|
||||
<div className="flex gap-2 shrink-0 pt-2">
|
||||
{project.featured && (
|
||||
<span className="px-4 py-1.5 bg-stone-900 text-stone-50 text-xs font-bold rounded-full shadow-sm">
|
||||
Featured
|
||||
{tShared("featured")}
|
||||
</span>
|
||||
)}
|
||||
<span className="px-4 py-1.5 bg-white border border-stone-200 text-stone-600 text-xs font-medium rounded-full shadow-sm">
|
||||
@@ -99,7 +104,7 @@ export default function ProjectDetailClient({
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar size={18} />
|
||||
<span className="font-mono">
|
||||
{new Date(project.date).toLocaleDateString(undefined, {
|
||||
{new Date(project.date).toLocaleDateString(locale || undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
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">
|
||||
<h3 className="font-bold text-stone-900 mb-4 flex items-center gap-2">
|
||||
<Share2 size={18} />
|
||||
Project Links
|
||||
{tDetail("links")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{project.live && project.live.trim() && project.live !== "#" ? (
|
||||
@@ -193,12 +198,12 @@ export default function ProjectDetailClient({
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between w-full px-4 py-3 bg-stone-900 text-stone-50 rounded-xl font-medium hover:bg-stone-800 hover:scale-[1.02] transition-all shadow-md group"
|
||||
>
|
||||
<span>Live Demo</span>
|
||||
<span>{tDetail("liveDemo")}</span>
|
||||
<ExternalLink size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</a>
|
||||
) : (
|
||||
<div className="px-4 py-3 bg-stone-100 text-stone-400 rounded-xl font-medium text-sm text-center border border-stone-200 cursor-not-allowed">
|
||||
Live demo not available
|
||||
{tDetail("liveNotAvailable")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -209,14 +214,14 @@ export default function ProjectDetailClient({
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between w-full px-4 py-3 bg-white border border-stone-200 text-stone-700 rounded-xl font-medium hover:bg-stone-50 hover:text-stone-900 hover:border-stone-300 transition-all shadow-sm group"
|
||||
>
|
||||
<span>View Source</span>
|
||||
<span>{tDetail("viewSource")}</span>
|
||||
<GithubIcon size={18} className="group-hover:rotate-12 transition-transform" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-stone-100">
|
||||
<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">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ExternalLink, Github, Calendar, ArrowLeft, Search } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ProjectListItem = {
|
||||
id: number;
|
||||
@@ -27,7 +28,11 @@ export default function ProjectsPageClient({
|
||||
projects: ProjectListItem[];
|
||||
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 [mounted, setMounted] = useState(false);
|
||||
|
||||
@@ -37,13 +42,13 @@ export default function ProjectsPageClient({
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const unique = Array.from(new Set(projects.map((p) => p.category))).filter(Boolean);
|
||||
return ["All", ...unique];
|
||||
return ["all", ...unique];
|
||||
}, [projects]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
let result = projects;
|
||||
|
||||
if (selectedCategory !== "All") {
|
||||
if (selectedCategory !== "all") {
|
||||
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"
|
||||
>
|
||||
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
||||
<span>Back to Home</span>
|
||||
<span>{tCommon("backToHome")}</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-black font-sans mb-6 text-stone-900 tracking-tight">
|
||||
My Projects
|
||||
{tList("title")}
|
||||
</h1>
|
||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">
|
||||
Explore my portfolio of projects, from web applications to mobile apps. Each project showcases different
|
||||
skills and technologies.
|
||||
</p>
|
||||
<p className="text-xl text-stone-600 max-w-3xl font-light leading-relaxed">{tList("intro")}</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 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"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{category === "all" ? tList("all") : category}
|
||||
</button>
|
||||
))}
|
||||
</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} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects..."
|
||||
placeholder={tList("searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-stone-200 rounded-full text-stone-800 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-stone-200 focus:border-stone-400 transition-all"
|
||||
@@ -172,7 +174,7 @@ export default function ProjectsPageClient({
|
||||
{project.featured && (
|
||||
<div className="absolute top-3 left-3 z-20">
|
||||
<div className="px-3 py-1 bg-[#292524]/80 backdrop-blur-md text-[#fdfcf8] text-[10px] font-bold uppercase tracking-widest rounded-full shadow-sm border border-white/10">
|
||||
Featured
|
||||
{tShared("featured")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -273,15 +275,15 @@ export default function ProjectsPageClient({
|
||||
|
||||
{filteredProjects.length === 0 && (
|
||||
<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
|
||||
onClick={() => {
|
||||
setSelectedCategory("All");
|
||||
setSelectedCategory("all");
|
||||
setSearchQuery("");
|
||||
}}
|
||||
className="mt-4 text-stone-800 font-medium hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
{tList("clearFilters")}
|
||||
</button>
|
||||
</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;
|
||||
title?: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
const locale = body.locale || "en";
|
||||
const title = body.title?.trim();
|
||||
const description = body.description?.trim();
|
||||
const content = typeof body.content === "string" ? body.content.trim() : undefined;
|
||||
|
||||
if (!title || !description) {
|
||||
return NextResponse.json({ error: "title and description are required" }, { status: 400 });
|
||||
@@ -59,10 +61,12 @@ export async function PUT(
|
||||
locale,
|
||||
title,
|
||||
description,
|
||||
content: content ?? null,
|
||||
},
|
||||
update: {
|
||||
title,
|
||||
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 [isCreating, setIsCreating] = useState(!projectId);
|
||||
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 [_isTyping, setIsTyping] = useState(false);
|
||||
const [history, setHistory] = useState<typeof formData[]>([]);
|
||||
@@ -96,6 +96,7 @@ function EditorPageContent() {
|
||||
setBaseTexts({
|
||||
title: foundProject.title || "",
|
||||
description: foundProject.description || "",
|
||||
content: foundProject.content || "",
|
||||
});
|
||||
const initialData = {
|
||||
title: foundProject.title || "",
|
||||
@@ -145,19 +146,64 @@ function EditorPageContent() {
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
const tr = data.translation as { title?: string; description?: string } | null;
|
||||
if (tr?.title && tr?.description) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
title: tr.title || prev.title,
|
||||
description: tr.description || prev.description,
|
||||
}));
|
||||
const tr = data.translation as { title?: string; description?: string; content?: unknown } | null;
|
||||
const translatedContent = (() => {
|
||||
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,
|
||||
title: tr?.title || prev.title,
|
||||
description: tr?.description || prev.description,
|
||||
content: translatedContent ?? prev.content,
|
||||
};
|
||||
return next;
|
||||
});
|
||||
if (translatedContent !== null) {
|
||||
shouldUpdateContentRef.current = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
@@ -188,6 +234,7 @@ function EditorPageContent() {
|
||||
live: "",
|
||||
image: "",
|
||||
};
|
||||
setBaseTexts({ title: "", description: "", content: "" });
|
||||
setFormData(initialData);
|
||||
setOriginalFormData(initialData);
|
||||
setHistory([initialData]);
|
||||
@@ -240,11 +287,12 @@ function EditorPageContent() {
|
||||
const saveTitle = editLocale === "en" ? formData.title.trim() : (baseTexts?.title || formData.title.trim());
|
||||
const saveDescription =
|
||||
editLocale === "en" ? formData.description.trim() : (baseTexts?.description || formData.description.trim());
|
||||
const saveContent = editLocale === "en" ? formData.content.trim() : (baseTexts?.content || formData.content.trim());
|
||||
|
||||
const saveData = {
|
||||
title: saveTitle,
|
||||
description: saveDescription,
|
||||
content: formData.content.trim(),
|
||||
content: saveContent,
|
||||
category: formData.category,
|
||||
tags: formData.tags,
|
||||
github: formData.github.trim() || null,
|
||||
@@ -302,12 +350,21 @@ function EditorPageContent() {
|
||||
locale: editLocale,
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim(),
|
||||
content: formData.content.trim(),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// ignore translation save failures
|
||||
}
|
||||
}
|
||||
|
||||
if (editLocale === "en") {
|
||||
setBaseTexts({
|
||||
title: savedProject.title || "",
|
||||
description: savedProject.description || "",
|
||||
content: savedProject.content || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Update project ID if it was a new project
|
||||
if (!projectId && savedProject.id) {
|
||||
@@ -706,27 +763,40 @@ function EditorPageContent() {
|
||||
<label className="block text-sm font-medium text-stone-300 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<div className="custom-select">
|
||||
<select
|
||||
value={editLocale}
|
||||
onChange={(e) => {
|
||||
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="de">Deutsch</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="custom-select">
|
||||
<select
|
||||
value={editLocale}
|
||||
onChange={(e) => switchLocale(e.target.value)}
|
||||
>
|
||||
<option value="en">English (default)</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
</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" && (
|
||||
<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>
|
||||
)}
|
||||
</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:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: postgres:16-alpine
|
||||
container_name: portfolio_postgres_dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: portfolio_dev
|
||||
POSTGRES_USER: portfolio_user
|
||||
@@ -24,6 +26,8 @@ services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: portfolio_redis_dev
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_dev_data:/data
|
||||
networks:
|
||||
|
||||
@@ -30,6 +30,10 @@ N8N_WEBHOOK_URL=https://n8n.dk0.dev
|
||||
N8N_SECRET_TOKEN=your-n8n-secret-token
|
||||
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
|
||||
# JWT_SECRET=your-jwt-secret
|
||||
# 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",
|
||||
"rejectAll": "Alles ablehnen",
|
||||
"hide": "Ausblenden"
|
||||
}
|
||||
,
|
||||
},
|
||||
"home": {
|
||||
"hero": {
|
||||
"features": {
|
||||
@@ -59,7 +58,7 @@
|
||||
"selfHosting": "Self-Hosting & DevOps",
|
||||
"gaming": "Gaming",
|
||||
"gameServers": "Game-Server einrichten",
|
||||
"jogging": "Joggen zum Kopf freibekommen und aktiv bleiben"
|
||||
"jogging": "Joggen la Kopf freibekommen und aktiv bleiben"
|
||||
},
|
||||
"currentlyReading": {
|
||||
"title": "Aktuell am Lesen",
|
||||
@@ -112,8 +111,27 @@
|
||||
"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": {
|
||||
"role": "Software Engineer",
|
||||
"madeIn": "Made in Germany",
|
||||
@@ -124,4 +142,3 @@
|
||||
"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": {
|
||||
"role": "Software Engineer",
|
||||
"madeIn": "Made in Germany",
|
||||
|
||||
@@ -33,14 +33,15 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
// Performance optimizations
|
||||
// NOTE: `optimizePackageImports` can cause dev-time webpack runtime issues with some setups.
|
||||
// Keep it enabled for production builds only.
|
||||
experimental:
|
||||
process.env.NODE_ENV === "production"
|
||||
? {
|
||||
optimizePackageImports: ["lucide-react", "framer-motion"],
|
||||
}
|
||||
: {},
|
||||
: {
|
||||
// In development, enable webpack build worker for faster builds
|
||||
webpackBuildWorker: true,
|
||||
},
|
||||
|
||||
// Image optimization
|
||||
images: {
|
||||
@@ -63,7 +64,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
// Webpack configuration
|
||||
webpack: (config) => {
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
// Fix for module resolution issues
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
@@ -72,6 +73,27 @@ const nextConfig: NextConfig = {
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ exec('docker-compose --version', (error) => {
|
||||
"postgresql://portfolio_user:portfolio_dev_pass@localhost:5432/portfolio_dev?schema=public",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
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.
|
||||
|
||||
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