Teil 1 des "mehrsprachig variabel"-Umbaus (Speiseplan-Inhalte folgen
in einer späteren Version). Sprachen sind jetzt zur Laufzeit über eine
neue Admin-Seite "Sprachen" verwaltbar statt fest im Code zu stehen:
- Neue Tabellen languages/ui_translations (Migration 0004), bestehende
Englisch-/Polnisch-Texte per generiertem Seed automatisch übernommen
(Migration 0005) - keine manuelle Nacharbeit, nichts geht verloren.
- lib/i18n/flatten.ts: verschachtelte Dict-Objekte (inkl. Arrays wie
weekdays.long) <-> flache Schlüssel-Wert-Paare, Basis für Vorlage-
Export/-Import.
- lib/i18n/template.ts: Vollständigkeits-/Veraltet-Berechnung (Hash
über die DE-Schlüsselliste) und Import-Validierung (Platzhalter wie
{message} müssen erhalten bleiben, unbekannte Schlüssel werden
ignoriert statt Fehler zu werfen).
- getDictionary()/getLocale() (lib/i18n/index.ts) jetzt asynchron und
DB-gestützt für Nicht-Basis-Sprachen, mit Fallback auf Deutsch für
fehlende Schlüssel. Locale-Typ bewusst von festem TypeScript-Tupel
auf string generalisiert (Kompromiss: Compiler prüft Vollständigkeit
nicht mehr automatisch, das übernimmt jetzt die Veraltet-Erkennung
zur Laufzeit).
- LanguageSwitcher/LocaleProvider lesen aktive Sprachen jetzt aus dem
Context statt aus einer festen Konstante; blendet sich bei nur einer
aktiven Sprache automatisch aus.
- Neue Admin-Routen app/api/admin/languages/* (Liste inkl.
Vollständigkeit, Anlegen, Aktivieren/Deaktivieren mit
"mindestens eine muss aktiv bleiben"-Schutz, Löschen außer
Basis-Sprache, Vorlage-Download, Datei-Import) + LanguageManager.tsx.
Vollständig gegen echten DB-Zustand getestet: Vorlage-Export/-Import
inkl. Platzhalter-Validierung, neue Sprache anlegen, auf eine Sprache
reduzieren (Umschalter verschwindet, "letzte Sprache"-Schutz greift),
Veraltet-Erkennung nach simuliertem neuen Schlüssel, Cascade-Delete.
Version 1.10.0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
107 lines
3.4 KiB
TypeScript
107 lines
3.4 KiB
TypeScript
import "server-only";
|
|
import { query, queryOne, withTransaction } from "@/lib/db";
|
|
|
|
export interface LanguageRow {
|
|
code: string;
|
|
label: string;
|
|
is_base: boolean;
|
|
active: boolean;
|
|
template_version: string | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
export async function listLanguages(): Promise<LanguageRow[]> {
|
|
return query<LanguageRow>(
|
|
"select * from languages order by is_base desc, code"
|
|
);
|
|
}
|
|
|
|
export async function listActiveLanguageCodes(): Promise<string[]> {
|
|
const rows = await query<{ code: string }>(
|
|
"select code from languages where active = true order by is_base desc, code"
|
|
);
|
|
return rows.map((r) => r.code);
|
|
}
|
|
|
|
export async function getLanguage(code: string): Promise<LanguageRow | null> {
|
|
return queryOne<LanguageRow>("select * from languages where code = $1", [code]);
|
|
}
|
|
|
|
export async function createLanguage(code: string, label: string): Promise<LanguageRow> {
|
|
return (await queryOne<LanguageRow>(
|
|
`insert into languages (code, label, is_base, active, template_version)
|
|
values ($1, $2, false, true, null) returning *`,
|
|
[code, label]
|
|
))!;
|
|
}
|
|
|
|
export async function updateLanguageLabel(code: string, label: string): Promise<LanguageRow | null> {
|
|
return queryOne<LanguageRow>(
|
|
"update languages set label = $1, updated_at = now() where code = $2 returning *",
|
|
[label, code]
|
|
);
|
|
}
|
|
|
|
/** Wirft, wenn dadurch keine aktive Sprache mehr übrig bliebe. */
|
|
export async function setLanguageActive(code: string, active: boolean): Promise<LanguageRow> {
|
|
return withTransaction(async (client) => {
|
|
if (!active) {
|
|
const { rows } = await client.query<{ count: string }>(
|
|
"select count(*)::int as count from languages where active = true and code != $1",
|
|
[code]
|
|
);
|
|
if (Number(rows[0].count) === 0) {
|
|
throw new Error("cannot_deactivate_last");
|
|
}
|
|
}
|
|
const { rows } = await client.query<LanguageRow>(
|
|
"update languages set active = $1, updated_at = now() where code = $2 returning *",
|
|
[active, code]
|
|
);
|
|
return rows[0];
|
|
});
|
|
}
|
|
|
|
export async function deleteLanguage(code: string): Promise<void> {
|
|
const lang = await getLanguage(code);
|
|
if (lang?.is_base) throw new Error("cannot_delete_base");
|
|
await query("delete from languages where code = $1", [code]);
|
|
}
|
|
|
|
export interface TranslationEntry {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
export async function listTranslations(code: string): Promise<TranslationEntry[]> {
|
|
return query<TranslationEntry>(
|
|
"select key, value from ui_translations where language_code = $1",
|
|
[code]
|
|
);
|
|
}
|
|
|
|
/** Übernimmt neu importierte Übersetzungen (Insert/Update je Schlüssel)
|
|
* und aktualisiert den gespeicherten Vorlagen-Stand. Bewusst kein
|
|
* vollständiges Ersetzen: ein erneuter Import mit nur den zuvor
|
|
* fehlenden Schlüsseln darf bereits vorhandene Übersetzungen nicht
|
|
* löschen. */
|
|
export async function importTranslations(
|
|
code: string,
|
|
entries: TranslationEntry[],
|
|
templateVersion: string
|
|
): Promise<void> {
|
|
await withTransaction(async (client) => {
|
|
for (const entry of entries) {
|
|
await client.query(
|
|
`insert into ui_translations (language_code, key, value) values ($1, $2, $3)
|
|
on conflict (language_code, key) do update set value = excluded.value`,
|
|
[code, entry.key, entry.value]
|
|
);
|
|
}
|
|
await client.query(
|
|
"update languages set template_version = $1, updated_at = now() where code = $2",
|
|
[templateVersion, code]
|
|
);
|
|
});
|
|
}
|