feat: Speiseplan-Inhalte variabel mehrsprachig (Teil 2)

Gerichtnamen, Essenszeiten und Allergen-/Zusatzstoff-Bezeichnungen sind
jetzt genauso dynamisch mehrsprachig wie die Bedienoberfläche aus
v1.10.0, statt fest auf Englisch/Polnisch begrenzt zu sein. Neue
Übersetzungstabellen ersetzen die alten name_en/name_pl-Spalten,
DeepL-Auto-Übersetzung und CSV-Import/-Export unterstützen jetzt
beliebig viele aktive Sprachen.
This commit is contained in:
2026-07-20 11:10:06 +02:00
parent 0d69d77d4e
commit cd8b0fab9f
24 changed files with 723 additions and 290 deletions
+85
View File
@@ -0,0 +1,85 @@
import "server-only";
import { query } from "@/lib/db";
// Gemeinsame Zugriffsschicht für die vier Übersetzungstabellen aus
// db/migrations/0006_content_translations.sql. Die Wert-Spalte heißt
// in allen vieren bewusst einheitlich "name" (auch bei
// section_translations, obwohl sections.label selbst anders heißt) –
// vereinfacht diese generischen Helfer, statt zusätzlich eine Spalten-
// Namens-Zuordnung pflegen zu müssen.
export type TranslationTable =
| "dish_translations"
| "section_translations"
| "allergen_translations"
| "additive_translations";
const ID_COLUMN: Record<TranslationTable, string> = {
dish_translations: "dish_id",
section_translations: "section_key",
allergen_translations: "allergen_code",
additive_translations: "additive_code",
};
/** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */
export async function loadTranslations(
table: TranslationTable
): Promise<Map<string, Record<string, string>>> {
const idCol = ID_COLUMN[table];
const rows = await query<{ id: string; language_code: string; name: string }>(
`select ${idCol} as id, language_code, name from ${table}`
);
return groupByParent(rows);
}
/** Übersetzungen nur für eine Teilmenge von Eltern-IDs laden (z. B. die
* Gerichte des aktuell angezeigten Wochenplan-Zeitraums). */
export async function loadTranslationsFor(
table: TranslationTable,
ids: string[]
): Promise<Map<string, Record<string, string>>> {
if (ids.length === 0) return new Map();
const idCol = ID_COLUMN[table];
const rows = await query<{ id: string; language_code: string; name: string }>(
`select ${idCol} as id, language_code, name from ${table} where ${idCol} = any($1)`,
[ids]
);
return groupByParent(rows);
}
function groupByParent(
rows: { id: string; language_code: string; name: string }[]
): Map<string, Record<string, string>> {
const map = new Map<string, Record<string, string>>();
for (const r of rows) {
if (!map.has(r.id)) map.set(r.id, {});
map.get(r.id)![r.language_code] = r.name;
}
return map;
}
/** Übersetzungen eines Eintrags für die übergebenen Sprachen setzen –
* ein leerer/nur-Leerzeichen-Wert löscht die Zeile für diese Sprache
* wieder (z. B. wenn ein Formularfeld geleert wird), statt eine leere
* Übersetzung zu speichern. */
export async function replaceTranslations(
table: TranslationTable,
id: string,
translations: Record<string, string | null | undefined>
): Promise<void> {
const idCol = ID_COLUMN[table];
for (const [languageCode, rawValue] of Object.entries(translations)) {
const value = rawValue?.trim();
if (!value) {
await query(`delete from ${table} where ${idCol} = $1 and language_code = $2`, [
id,
languageCode,
]);
continue;
}
await query(
`insert into ${table} (${idCol}, language_code, name) values ($1, $2, $3)
on conflict (${idCol}, language_code) do update set name = excluded.name`,
[id, languageCode, value]
);
}
}