Files
florian.wildeandClaude Sonnet 5 236f8c9490 feat: Dynamisches Sprachpaket-System für die Bedienoberfläche
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>
2026-07-20 10:34:51 +02:00

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