From 37a1bc4e1854d013afe11b9f21ebe2d4547ad060 Mon Sep 17 00:00:00 2001 From: denshooter Date: Thu, 22 Jan 2026 20:56:35 +0100 Subject: [PATCH] locale upgrade --- DIRECTUS_CHECKLIST.md | 269 +++++++++++++++++++++ DIRECTUS_MIGRATION.md | 221 +++++++++++++++++ app/[locale]/layout.tsx | 12 +- app/[locale]/page.tsx | 11 +- app/[locale]/projects/[slug]/page.tsx | 18 +- app/[locale]/projects/page.tsx | 9 +- app/_ui/HomePageServer.tsx | 136 +++++++++++ app/_ui/ProjectDetailClient.tsx | 21 +- app/_ui/ProjectsPageClient.tsx | 32 +-- app/api/i18n/[namespace]/route.ts | 79 ++++++ app/api/messages/route.ts | 94 +++++++ app/api/projects/[id]/translation/route.ts | 4 + app/components/ClientWrappers.tsx | 111 +++++++++ app/components/Header.server.tsx | 12 + app/components/HeaderClient.tsx | 249 +++++++++++++++++++ app/editor/page.tsx | 124 +++++++--- components/I18nWrapper.tsx | 59 +++++ docker-compose.dev.minimal.yml | 6 +- env.example | 4 + hooks/useDirectusTranslations.tsx | 37 +++ lib/directus.ts | 151 ++++++++++++ lib/i18n-loader.ts | 133 ++++++++++ lib/translations-loader.ts | 206 ++++++++++++++++ messages/de.json | 29 ++- messages/en.json | 21 ++ next.config.ts | 30 ++- scripts/dev-minimal.js | 2 + types/translations.ts | 108 +++++++++ 28 files changed, 2117 insertions(+), 71 deletions(-) create mode 100644 DIRECTUS_CHECKLIST.md create mode 100644 DIRECTUS_MIGRATION.md create mode 100644 app/_ui/HomePageServer.tsx create mode 100644 app/api/i18n/[namespace]/route.ts create mode 100644 app/api/messages/route.ts create mode 100644 app/components/ClientWrappers.tsx create mode 100644 app/components/Header.server.tsx create mode 100644 app/components/HeaderClient.tsx create mode 100644 components/I18nWrapper.tsx create mode 100644 hooks/useDirectusTranslations.tsx create mode 100644 lib/directus.ts create mode 100644 lib/i18n-loader.ts create mode 100644 lib/translations-loader.ts create mode 100644 types/translations.ts diff --git a/DIRECTUS_CHECKLIST.md b/DIRECTUS_CHECKLIST.md new file mode 100644 index 0000000..de084ff --- /dev/null +++ b/DIRECTUS_CHECKLIST.md @@ -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ß! 🚀 diff --git a/DIRECTUS_MIGRATION.md b/DIRECTUS_MIGRATION.md new file mode 100644 index 0000000..5927bbc --- /dev/null +++ b/DIRECTUS_MIGRATION.md @@ -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 ; +} +``` + +### 2. Server Component lädt Translations +```tsx +// app/_ui/HomePageServer.tsx +export default async function HomePageServer({ locale }) { + const heroT = await getHeroTranslations(locale); + // ... + return ; +} +``` + +### 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 ( + + + + ); +} +``` + +## 🔍 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) diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index b9e024d..ec21198 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -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 ( diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 4068978..6e59d5e 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -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 ; +export default async function Page({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + return ; } diff --git a/app/[locale]/projects/[slug]/page.tsx b/app/[locale]/projects/[slug]/page.tsx index b5571e5..9311494 100644 --- a/app/[locale]/projects/[slug]/page.tsx +++ b/app/[locale]/projects/[slug]/page.tsx @@ -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).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 ; diff --git a/app/[locale]/projects/page.tsx b/app/[locale]/projects/page.tsx index 0dffa8d..f194b43 100644 --- a/app/[locale]/projects/page.tsx +++ b/app/[locale]/projects/page.tsx @@ -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, diff --git a/app/_ui/HomePageServer.tsx b/app/_ui/HomePageServer.tsx new file mode 100644 index 0000000..cbeff71 --- /dev/null +++ b/app/_ui/HomePageServer.tsx @@ -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 ( +
+