locale upgrade
This commit is contained in:
151
lib/directus.ts
Normal file
151
lib/directus.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Directus API Client (REST-based, no SDK dependencies)
|
||||
*/
|
||||
|
||||
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'https://cms.dk0.dev';
|
||||
const DIRECTUS_TOKEN = process.env.DIRECTUS_STATIC_TOKEN || '';
|
||||
|
||||
// Mapping: next-intl locale → Directus language code
|
||||
const localeToDirectus: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
};
|
||||
|
||||
function toDirectusLocale(locale: string): string {
|
||||
return localeToDirectus[locale] || locale;
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: any;
|
||||
}
|
||||
|
||||
async function directusRequest<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<T | null> {
|
||||
// Wenn kein Token gesetzt, skip Directus (nutze JSON fallback)
|
||||
if (!DIRECTUS_TOKEN || DIRECTUS_TOKEN === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = `${DIRECTUS_URL}/graphql`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(options.body || {}),
|
||||
// Timeout nach 2 Sekunden
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Collection noch nicht erstellt? Stille fallback zu JSON
|
||||
const text = await response.text();
|
||||
if (text.includes('GRAPHQL_VALIDATION') || text.includes('Cannot query field')) {
|
||||
// Stille: Collection existiert noch nicht
|
||||
return null;
|
||||
}
|
||||
console.error(`Directus error: ${response.status}`, text);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Prüfe auf GraphQL errors
|
||||
if (data?.errors) {
|
||||
// Stille: Collection noch nicht ready
|
||||
return null;
|
||||
}
|
||||
|
||||
return data?.data || null;
|
||||
} catch (error: any) {
|
||||
// Timeout oder Network Error - stille fallback
|
||||
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
||||
return null;
|
||||
}
|
||||
// Andere Errors nur in dev loggen
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Directus request failed:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMessage(key: string, locale: string): Promise<string | null> {
|
||||
const directusLocale = toDirectusLocale(locale);
|
||||
|
||||
// GraphQL Query für Directus Native Translations
|
||||
// Hole alle translations, filter client-side da GraphQL filter komplex ist
|
||||
const query = `
|
||||
query {
|
||||
messages(filter: {key: {_eq: "${key}"}}, limit: 1) {
|
||||
key
|
||||
translations {
|
||||
value
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await directusRequest(
|
||||
'',
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const messages = (result as any)?.messages;
|
||||
if (!messages || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hole die Translation für die gewünschte Locale (client-side filter)
|
||||
const translations = messages[0]?.translations || [];
|
||||
const translation = translations.find((t: any) =>
|
||||
t.languages_code?.code === directusLocale
|
||||
);
|
||||
|
||||
return translation?.value || null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch message ${key} (${locale}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContentPage(
|
||||
slug: string,
|
||||
locale: string
|
||||
): Promise<any | null> {
|
||||
const directusLocale = toDirectusLocale(locale);
|
||||
const query = `
|
||||
query {
|
||||
content_pages(filter: {slug: {_eq: "${slug}"}, locale: {_eq: "${directusLocale}"}}, limit: 1) {
|
||||
id
|
||||
slug
|
||||
locale
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await directusRequest(
|
||||
'',
|
||||
{ body: { query } }
|
||||
);
|
||||
|
||||
const pages = (result as any)?.content_pages;
|
||||
return pages?.[0] || null;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch content page ${slug} (${locale}):`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
133
lib/i18n-loader.ts
Normal file
133
lib/i18n-loader.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* i18n Loader with Directus + JSON Fallback
|
||||
* - Fetches from Directus first
|
||||
* - Falls back to JSON files if not found
|
||||
* - Caches results (5 min TTL)
|
||||
*/
|
||||
|
||||
import { getMessage, getContentPage } from './directus';
|
||||
import enMessages from '@/messages/en.json';
|
||||
import deMessages from '@/messages/de.json';
|
||||
|
||||
const jsonFallback = { en: enMessages, de: deMessages };
|
||||
|
||||
// Simple in-memory cache
|
||||
const cache = new Map<string, { value: any; expires: number }>();
|
||||
|
||||
function setCached(key: string, value: any, ttlSeconds = 300) {
|
||||
cache.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
|
||||
}
|
||||
|
||||
function getCached(key: string): any | null {
|
||||
const hit = cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() > hit.expires) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a localized message by key
|
||||
* Tries: Directus (requested locale) → Directus (EN) → JSON (requested locale) → JSON (EN)
|
||||
*/
|
||||
export async function getLocalizedMessage(
|
||||
key: string,
|
||||
locale: string
|
||||
): Promise<string> {
|
||||
const cacheKey = `msg:${key}:${locale}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
|
||||
// Try Directus with requested locale
|
||||
const dbValue = await getMessage(key, locale);
|
||||
if (dbValue) {
|
||||
setCached(cacheKey, dbValue);
|
||||
return dbValue;
|
||||
}
|
||||
|
||||
// Fallback to EN in Directus if not EN already
|
||||
if (locale !== 'en') {
|
||||
const dbValueEn = await getMessage(key, 'en');
|
||||
if (dbValueEn) {
|
||||
setCached(cacheKey, dbValueEn);
|
||||
return dbValueEn;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to JSON file (normalize locale to 'en' or 'de')
|
||||
const normalizedLocale = locale.startsWith('de') ? 'de' : 'en';
|
||||
const jsonValue = getNestedValue(jsonFallback[normalizedLocale as 'en' | 'de'], key);
|
||||
if (jsonValue) {
|
||||
setCached(cacheKey, jsonValue);
|
||||
return jsonValue;
|
||||
}
|
||||
|
||||
// Fallback to EN JSON
|
||||
if (normalizedLocale !== 'en') {
|
||||
const jsonValueEn = getNestedValue(jsonFallback['en'], key);
|
||||
if (jsonValueEn) {
|
||||
setCached(cacheKey, jsonValueEn);
|
||||
return jsonValueEn;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return the key itself
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a localized content page by slug
|
||||
* Tries: Directus (requested locale) → Directus (EN)
|
||||
*/
|
||||
export async function getLocalizedContent(
|
||||
slug: string,
|
||||
locale: string
|
||||
): Promise<any | null> {
|
||||
const cacheKey = `page:${slug}:${locale}`;
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached !== null) return cached;
|
||||
if (cached === null && cache.has(cacheKey)) return null; // Already checked, not found
|
||||
|
||||
// Try Directus with requested locale
|
||||
const dbPage = await getContentPage(slug, locale);
|
||||
if (dbPage) {
|
||||
setCached(cacheKey, dbPage);
|
||||
return dbPage;
|
||||
}
|
||||
|
||||
// Fallback to EN in Directus
|
||||
if (locale !== 'en') {
|
||||
const dbPageEn = await getContentPage(slug, 'en');
|
||||
if (dbPageEn) {
|
||||
setCached(cacheKey, dbPageEn);
|
||||
return dbPageEn;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
setCached(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Get nested value from object
|
||||
* Example: "nav.home" → obj.nav.home
|
||||
*/
|
||||
function getNestedValue(obj: any, path: string): any {
|
||||
const keys = path.split('.');
|
||||
let value = obj;
|
||||
for (const key of keys) {
|
||||
value = value?.[key];
|
||||
if (value === undefined) return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (useful for webhooks/revalidation)
|
||||
*/
|
||||
export function clearI18nCache() {
|
||||
cache.clear();
|
||||
}
|
||||
206
lib/translations-loader.ts
Normal file
206
lib/translations-loader.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { getLocalizedMessage } from '@/lib/i18n-loader';
|
||||
import type {
|
||||
NavTranslations,
|
||||
FooterTranslations,
|
||||
HeroTranslations,
|
||||
AboutTranslations,
|
||||
ProjectsTranslations,
|
||||
ContactTranslations,
|
||||
ConsentTranslations,
|
||||
} from '@/types/translations';
|
||||
|
||||
/**
|
||||
* Lädt alle Translations für eine Section aus Directus
|
||||
* Nutzt optimierte Batch-Abfragen wo möglich
|
||||
*/
|
||||
|
||||
export async function getNavTranslations(locale: string): Promise<NavTranslations> {
|
||||
const [home, about, projects, contact] = await Promise.all([
|
||||
getLocalizedMessage('nav.home', locale),
|
||||
getLocalizedMessage('nav.about', locale),
|
||||
getLocalizedMessage('nav.projects', locale),
|
||||
getLocalizedMessage('nav.contact', locale),
|
||||
]);
|
||||
|
||||
return { home, about, projects, contact };
|
||||
}
|
||||
|
||||
export async function getFooterTranslations(locale: string): Promise<FooterTranslations> {
|
||||
const [role, description, privacy, imprint, copyright, madeWith, resetConsent] = await Promise.all([
|
||||
getLocalizedMessage('footer.role', locale),
|
||||
getLocalizedMessage('footer.description', locale),
|
||||
getLocalizedMessage('footer.links.privacy', locale),
|
||||
getLocalizedMessage('footer.links.imprint', locale),
|
||||
getLocalizedMessage('footer.copyright', locale),
|
||||
getLocalizedMessage('footer.madeWith', locale),
|
||||
getLocalizedMessage('footer.resetConsent', locale),
|
||||
]);
|
||||
|
||||
return {
|
||||
role,
|
||||
description,
|
||||
links: { privacy, imprint },
|
||||
copyright,
|
||||
madeWith,
|
||||
resetConsent,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHeroTranslations(locale: string): Promise<HeroTranslations> {
|
||||
const keys = [
|
||||
'home.hero.greeting',
|
||||
'home.hero.name',
|
||||
'home.hero.role',
|
||||
'home.hero.description',
|
||||
'home.hero.ctaWork',
|
||||
'home.hero.ctaContact',
|
||||
'home.hero.features.f1',
|
||||
'home.hero.features.f2',
|
||||
'home.hero.features.f3',
|
||||
'home.hero.scrollDown',
|
||||
];
|
||||
|
||||
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
||||
|
||||
return {
|
||||
greeting: values[0],
|
||||
name: values[1],
|
||||
role: values[2],
|
||||
description: values[3],
|
||||
cta: {
|
||||
projects: values[4],
|
||||
contact: values[5],
|
||||
},
|
||||
features: {
|
||||
f1: values[6],
|
||||
f2: values[7],
|
||||
f3: values[8],
|
||||
},
|
||||
scrollDown: values[9],
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAboutTranslations(locale: string): Promise<AboutTranslations> {
|
||||
// Diese Keys sind NICHT korrekt - wir nutzen nur für Type Compatibility
|
||||
// Die About Component nutzt actually: title, p1, p2, p3, hobbiesTitle, hobbies.*, techStackTitle, techStack.*
|
||||
// Lade alle benötigten Keys
|
||||
const keys = [
|
||||
'home.about.title',
|
||||
'home.about.description',
|
||||
'home.about.techStack.title',
|
||||
'home.about.techStack.categories.frontendMobile',
|
||||
'home.about.techStack.categories.backendDevops',
|
||||
'home.about.techStack.categories.toolsAutomation',
|
||||
'home.about.techStack.categories.securityAdmin',
|
||||
'home.about.techStack.items.selfHostedServices',
|
||||
'home.about.hobbiesTitle', // Nicht "interests.title"!
|
||||
'home.about.hobbies.selfHosting',
|
||||
'home.about.hobbies.gaming',
|
||||
'home.about.hobbies.gameServers',
|
||||
'home.about.hobbies.jogging',
|
||||
'home.about.p1',
|
||||
'home.about.p2',
|
||||
'home.about.p3',
|
||||
'home.about.funFactTitle',
|
||||
'home.about.funFactBody',
|
||||
'home.about.techStackTitle',
|
||||
];
|
||||
|
||||
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
||||
|
||||
return {
|
||||
title: values[0],
|
||||
description: values[1],
|
||||
techStack: {
|
||||
title: values[2],
|
||||
categories: {
|
||||
frontendMobile: values[3],
|
||||
backendDevops: values[4],
|
||||
toolsAutomation: values[5],
|
||||
securityAdmin: values[6],
|
||||
},
|
||||
items: {
|
||||
selfHostedServices: values[7],
|
||||
},
|
||||
},
|
||||
interests: {
|
||||
title: values[8], // hobbiesTitle
|
||||
cybersecurity: {
|
||||
title: values[9], // hobbies.selfHosting
|
||||
description: values[10], // hobbies.gaming
|
||||
},
|
||||
selfHosting: {
|
||||
title: values[11], // hobbies.gameServers
|
||||
description: values[12], // hobbies.jogging
|
||||
},
|
||||
gaming: {
|
||||
title: values[13], // p1
|
||||
description: values[14], // p2
|
||||
},
|
||||
automation: {
|
||||
title: values[15], // p3
|
||||
description: values[16], // funFactTitle
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProjectsTranslations(locale: string): Promise<ProjectsTranslations> {
|
||||
const [title, viewAll] = await Promise.all([
|
||||
getLocalizedMessage('home.projects.title', locale),
|
||||
getLocalizedMessage('home.projects.viewAll', locale),
|
||||
]);
|
||||
|
||||
return { title, viewAll };
|
||||
}
|
||||
|
||||
export async function getContactTranslations(locale: string): Promise<ContactTranslations> {
|
||||
const keys = [
|
||||
'home.contact.title',
|
||||
'home.contact.description',
|
||||
'home.contact.form.name',
|
||||
'home.contact.form.email',
|
||||
'home.contact.form.message',
|
||||
'home.contact.form.send',
|
||||
'home.contact.form.sending',
|
||||
'home.contact.form.success',
|
||||
'home.contact.form.error',
|
||||
'home.contact.info.title',
|
||||
'home.contact.info.email',
|
||||
'home.contact.info.response',
|
||||
'home.contact.info.emailLabel',
|
||||
];
|
||||
|
||||
const values = await Promise.all(keys.map(key => getLocalizedMessage(key, locale)));
|
||||
|
||||
return {
|
||||
title: values[0],
|
||||
description: values[1],
|
||||
form: {
|
||||
name: values[2],
|
||||
email: values[3],
|
||||
message: values[4],
|
||||
send: values[5],
|
||||
sending: values[6],
|
||||
success: values[7],
|
||||
error: values[8],
|
||||
},
|
||||
info: {
|
||||
title: values[9],
|
||||
email: values[10],
|
||||
response: values[11],
|
||||
emailLabel: values[12],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getConsentTranslations(locale: string): Promise<ConsentTranslations> {
|
||||
const [title, description, accept, decline] = await Promise.all([
|
||||
getLocalizedMessage('consent.title', locale),
|
||||
getLocalizedMessage('consent.description', locale),
|
||||
getLocalizedMessage('consent.accept', locale),
|
||||
getLocalizedMessage('consent.decline', locale),
|
||||
]);
|
||||
|
||||
return { title, description, accept, decline };
|
||||
}
|
||||
Reference in New Issue
Block a user