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

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