locale upgrade

This commit is contained in:
2026-01-22 20:56:35 +01:00
parent 377631ee50
commit 37a1bc4e18
28 changed files with 2117 additions and 71 deletions

269
DIRECTUS_CHECKLIST.md Normal file
View 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
View 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)

View File

@@ -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}>

View File

@@ -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} />;
}

View File

@@ -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} />;

View File

@@ -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
View 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>
);
}

View File

@@ -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

View File

@@ -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>
)}

View 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
View 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;
}

View File

@@ -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,
},
});

View 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>
);
}

View 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} />;
}

View 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>
</>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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:

View File

@@ -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

View 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
View 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
View 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
View 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 };
}

View File

@@ -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"
}
}

View File

@@ -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",

View File

@@ -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;
},

View File

@@ -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
View 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;
}