From cd8b0fab9f4a8c3fe11b44e383c7199b5dac9701 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 20 Jul 2026 11:10:06 +0200 Subject: [PATCH] feat: Speiseplan-Inhalte variabel mehrsprachig (Teil 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 34 +++++++ app/api/additives/export/route.ts | 11 +- app/api/additives/import/route.ts | 19 ++-- app/api/allergens/export/route.ts | 11 +- app/api/allergens/import/route.ts | 19 ++-- app/api/dishes/[id]/route.ts | 32 ++++-- app/api/dishes/export/route.ts | 28 +++-- app/api/dishes/import/route.ts | 40 +++++--- app/api/dishes/route.ts | 44 ++++++-- app/api/sections/[key]/route.ts | 42 ++++++-- app/api/sections/route.ts | 39 +++++-- app/api/translate/route.ts | 70 ++++++++----- app/page.tsx | 34 ++++++- components/CodeListManager.tsx | 55 +++++----- components/DishManager.tsx | 65 ++++++------ components/SectionManager.tsx | 59 +++++------ db/migrations/0006_content_translations.sql | 71 +++++++++++++ db/seed-demo.sql | 103 +++++++++++++------ lib/db/code-lists.ts | 107 +++++++++++++------- lib/db/menu-entries.ts | 11 +- lib/db/translations.ts | 85 ++++++++++++++++ lib/i18n/dictionaries.ts | 4 - lib/types.ts | 28 +++-- package.json | 2 +- 24 files changed, 723 insertions(+), 290 deletions(-) create mode 100644 db/migrations/0006_content_translations.sql create mode 100644 lib/db/translations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e1119..81586f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert. Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung nach [Semantic Versioning](https://semver.org/lang/de/). +## [1.11.0] – 2026-07-20 + +### Hinzugefügt +- Speiseplan-Inhalte variabel gemacht (Teil 2 des "mehrsprachig + variabel"-Umbaus): Gerichtnamen, Essenszeiten-Bezeichnungen sowie + Allergen-/Zusatzstoff-Namen sind jetzt genauso dynamisch mehrsprachig + wie die Bedienoberfläche aus Version 1.10.0, statt auf feste + Englisch-/Polnisch-Spalten begrenzt zu sein: + - In den Verwaltungsseiten (Gerichte, Essenszeiten, Allergene & + Zusatzstoffe) erscheint jetzt automatisch ein Eingabefeld je aktiv + geschalteter Sprache – wird im Sprachen-Admin eine neue Sprache + angelegt, taucht dort sofort ein passendes Feld auf, ohne + Codeänderung. + - Der "Automatisch übersetzen"-Button (DeepL) übersetzt künftig in + alle aktiven, von DeepL unterstützten Sprachen statt fix nur + Englisch/Polnisch. + - CSV-Export/-Import für Gerichte, Allergene und Zusatzstoffe hat + jetzt eine Spalte je aktiver Sprache (`name_en`, `name_fr`, …) + statt der bisher fest verdrahteten `name_en`/`name_pl`-Spalten. + - Reduziert der Admin das Sprachenfeature im Sprachen-Bereich auf nur + Deutsch, verschwinden diese Zusatzfelder ebenfalls automatisch aus + allen drei Verwaltungsseiten. + +### Geändert +- Neue Migration `0006_content_translations.sql` ersetzt die festen + Spalten `name_en`/`name_pl` (Gerichte, Allergene, Zusatzstoffe) bzw. + `label_en`/`label_pl` (Essenszeiten) durch je eine + Übersetzungstabelle (`dish_translations`, `section_translations`, + `allergen_translations`, `additive_translations`); bestehende + Übersetzungen werden dabei automatisch übernommen. + **Vor dem Einspielen auf einem Produktivsystem unbedingt ein Backup + der Datenbank anlegen** – die alten Spalten werden nach der + Übernahme entfernt. + ## [1.10.0] – 2026-07-20 ### Hinzugefügt diff --git a/app/api/additives/export/route.ts b/app/api/additives/export/route.ts index bf65f13..293e726 100644 --- a/app/api/additives/export/route.ts +++ b/app/api/additives/export/route.ts @@ -1,14 +1,19 @@ import { listCodes } from "@/lib/db/code-lists"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { toCsv } from "@/lib/csv"; export async function GET() { try { await requireRole(["staff", "admin"]); - const items = await listCodes("additives"); + const [items, activeCodes] = await Promise.all([ + listCodes("additives"), + listActiveLanguageCodes(), + ]); + const langCodes = activeCodes.filter((c) => c !== "de"); const rows = [ - ["code", "name", "name_en", "name_pl"], - ...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]), + ["code", "name", ...langCodes.map((c) => `name_${c}`)], + ...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]), ]; return new Response(toCsv(rows), { headers: { diff --git a/app/api/additives/import/route.ts b/app/api/additives/import/route.ts index 8fa72b1..b467f9f 100644 --- a/app/api/additives/import/route.ts +++ b/app/api/additives/import/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { importCodes, type CodeImportRow } from "@/lib/db/code-lists"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import { parseCsvWithHeader } from "@/lib/csv"; @@ -22,8 +23,10 @@ export async function POST(request: Request) { if (codeIdx === -1 || nameIdx === -1) { return NextResponse.json({ error: "missing_columns" }, { status: 400 }); } - const nameEnIdx = header.indexOf("name_en"); - const namePlIdx = header.indexOf("name_pl"); + const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de"); + const langCols = activeCodes + .map((code) => ({ code, idx: header.indexOf(`name_${code}`) })) + .filter((c) => c.idx !== -1); const rows: CodeImportRow[] = []; const skipped: { row: number; reason: string }[] = []; @@ -35,12 +38,12 @@ export async function POST(request: Request) { skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" }); return; } - rows.push({ - code, - name, - name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, - name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, - }); + const translations: Record = {}; + for (const { code: lang, idx } of langCols) { + const value = c[idx]?.trim(); + if (value) translations[lang] = value; + } + rows.push({ code, name, translations }); }); const result = rows.length > 0 ? await importCodes("additives", rows) : { created: 0, updated: 0 }; diff --git a/app/api/allergens/export/route.ts b/app/api/allergens/export/route.ts index bafa4c5..8bd4fc5 100644 --- a/app/api/allergens/export/route.ts +++ b/app/api/allergens/export/route.ts @@ -1,14 +1,19 @@ import { listCodes } from "@/lib/db/code-lists"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { toCsv } from "@/lib/csv"; export async function GET() { try { await requireRole(["staff", "admin"]); - const items = await listCodes("allergens"); + const [items, activeCodes] = await Promise.all([ + listCodes("allergens"), + listActiveLanguageCodes(), + ]); + const langCodes = activeCodes.filter((c) => c !== "de"); const rows = [ - ["code", "name", "name_en", "name_pl"], - ...items.map((i) => [i.code, i.name, i.name_en ?? "", i.name_pl ?? ""]), + ["code", "name", ...langCodes.map((c) => `name_${c}`)], + ...items.map((i) => [i.code, i.name, ...langCodes.map((c) => i.translations[c] ?? "")]), ]; return new Response(toCsv(rows), { headers: { diff --git a/app/api/allergens/import/route.ts b/app/api/allergens/import/route.ts index c49203b..e5224b2 100644 --- a/app/api/allergens/import/route.ts +++ b/app/api/allergens/import/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { importCodes, type CodeImportRow } from "@/lib/db/code-lists"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import { parseCsvWithHeader } from "@/lib/csv"; @@ -22,8 +23,10 @@ export async function POST(request: Request) { if (codeIdx === -1 || nameIdx === -1) { return NextResponse.json({ error: "missing_columns" }, { status: 400 }); } - const nameEnIdx = header.indexOf("name_en"); - const namePlIdx = header.indexOf("name_pl"); + const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de"); + const langCols = activeCodes + .map((code) => ({ code, idx: header.indexOf(`name_${code}`) })) + .filter((c) => c.idx !== -1); const rows: CodeImportRow[] = []; const skipped: { row: number; reason: string }[] = []; @@ -35,12 +38,12 @@ export async function POST(request: Request) { skipped.push({ row: rowNum, reason: "Kürzel oder Bezeichnung fehlt" }); return; } - rows.push({ - code, - name, - name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, - name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, - }); + const translations: Record = {}; + for (const { code: lang, idx } of langCols) { + const value = c[idx]?.trim(); + if (value) translations[lang] = value; + } + rows.push({ code, name, translations }); }); const result = rows.length > 0 ? await importCodes("allergens", rows) : { created: 0, updated: 0 }; diff --git a/app/api/dishes/[id]/route.ts b/app/api/dishes/[id]/route.ts index 1a6f59d..405baec 100644 --- a/app/api/dishes/[id]/route.ts +++ b/app/api/dishes/[id]/route.ts @@ -1,9 +1,18 @@ import { NextResponse } from "next/server"; import { query, queryOne } from "@/lib/db"; +import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import type { Dish } from "@/lib/types"; +interface DishRow { + id: string; + name: string; + price: string; + allergens: string[]; + additives: string[]; +} + export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> } @@ -16,19 +25,26 @@ export async function PATCH( const { id } = await params; const body = (await request.json()) as { name: string; - name_en: string | null; - name_pl: string | null; price: number; allergens: string[]; additives: string[]; + translations?: Record; }; - const dish = await queryOne( - `update dishes set name = $1, name_en = $2, name_pl = $3, price = $4, - allergens = $5, additives = $6 - where id = $7 returning *`, - [body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives, id] + const row = await queryOne( + `update dishes set name = $1, price = $2, allergens = $3, additives = $4 + where id = $5 returning id, name, price, allergens, additives`, + [body.name, body.price, body.allergens, body.additives, id] ); - if (!dish) return NextResponse.json({ error: "not_found" }, { status: 404 }); + if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 }); + if (body.translations) { + await replaceTranslations("dish_translations", row.id, body.translations); + } + const translations = await loadTranslationsFor("dish_translations", [row.id]); + const dish: Dish = { + ...row, + price: Number(row.price), + translations: translations.get(row.id) ?? {}, + }; return NextResponse.json(dish); } catch (err) { const res = authErrorResponse(err); diff --git a/app/api/dishes/export/route.ts b/app/api/dishes/export/route.ts index bc284a0..18b85e5 100644 --- a/app/api/dishes/export/route.ts +++ b/app/api/dishes/export/route.ts @@ -1,19 +1,35 @@ import { query } from "@/lib/db"; +import { loadTranslationsFor } from "@/lib/db/translations"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { toCsv } from "@/lib/csv"; -import type { Dish } from "@/lib/types"; + +interface DishRow { + id: string; + name: string; + price: string; + allergens: string[]; + additives: string[]; +} export async function GET() { try { await requireRole(["staff", "admin"]); - const dishes = await query("select * from dishes order by name"); + const [dishes, activeCodes] = await Promise.all([ + query("select id, name, price, allergens, additives from dishes order by name"), + listActiveLanguageCodes(), + ]); + const langCodes = activeCodes.filter((c) => c !== "de"); + const translations = await loadTranslationsFor( + "dish_translations", + dishes.map((d) => d.id) + ); const rows = [ - ["name", "name_en", "name_pl", "price", "allergens", "additives"], + ["name", ...langCodes.map((c) => `name_${c}`), "price", "allergens", "additives"], ...dishes.map((d) => [ d.name, - d.name_en ?? "", - d.name_pl ?? "", - String(d.price), + ...langCodes.map((c) => translations.get(d.id)?.[c] ?? ""), + d.price, (d.allergens ?? []).join(";"), (d.additives ?? []).join(";"), ]), diff --git a/app/api/dishes/import/route.ts b/app/api/dishes/import/route.ts index 8861be3..6eb313d 100644 --- a/app/api/dishes/import/route.ts +++ b/app/api/dishes/import/route.ts @@ -1,5 +1,7 @@ import { NextResponse } from "next/server"; import { withTransaction } from "@/lib/db"; +import { replaceTranslations } from "@/lib/db/translations"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import { parseCsvWithHeader, splitCodes } from "@/lib/csv"; @@ -7,8 +9,7 @@ import { parseCsvWithHeader, splitCodes } from "@/lib/csv"; interface ImportRow { rowNum: number; name: string; - name_en: string | null; - name_pl: string | null; + translations: Record; price: number; allergens: string[]; additives: string[]; @@ -32,8 +33,10 @@ export async function POST(request: Request) { if (nameIdx === -1 || priceIdx === -1) { return NextResponse.json({ error: "missing_columns" }, { status: 400 }); } - const nameEnIdx = header.indexOf("name_en"); - const namePlIdx = header.indexOf("name_pl"); + const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de"); + const langCols = activeCodes + .map((code) => ({ code, idx: header.indexOf(`name_${code}`) })) + .filter((c) => c.idx !== -1); const allergensIdx = header.indexOf("allergens"); const additivesIdx = header.indexOf("additives"); @@ -52,11 +55,15 @@ export async function POST(request: Request) { skipped.push({ row: rowNum, reason: `Ungültiger Preis „${c[priceIdx] ?? ""}“` }); return; } + const translations: Record = {}; + for (const { code, idx } of langCols) { + const value = c[idx]?.trim(); + if (value) translations[code] = value; + } rows.push({ rowNum, name, - name_en: nameEnIdx >= 0 ? c[nameEnIdx]?.trim() || null : null, - name_pl: namePlIdx >= 0 ? c[namePlIdx]?.trim() || null : null, + translations, price, allergens: allergensIdx >= 0 ? splitCodes(c[allergensIdx]) : [], additives: additivesIdx >= 0 ? splitCodes(c[additivesIdx]) : [], @@ -65,6 +72,7 @@ export async function POST(request: Request) { let created = 0; let updated = 0; + const savedTranslations: { id: string; translations: Record }[] = []; if (rows.length > 0) { await withTransaction(async (client) => { for (const row of rows) { @@ -84,21 +92,27 @@ export async function POST(request: Request) { } if (existing.rows.length === 1) { await client.query( - `update dishes set name_en = $1, name_pl = $2, price = $3, allergens = $4, additives = $5 - where id = $6`, - [row.name_en, row.name_pl, row.price, row.allergens, row.additives, existing.rows[0].id] + `update dishes set price = $1, allergens = $2, additives = $3 where id = $4`, + [row.price, row.allergens, row.additives, existing.rows[0].id] ); + savedTranslations.push({ id: existing.rows[0].id, translations: row.translations }); updated++; } else { - await client.query( - `insert into dishes (name, name_en, name_pl, price, allergens, additives) - values ($1, $2, $3, $4, $5, $6)`, - [row.name, row.name_en, row.name_pl, row.price, row.allergens, row.additives] + const inserted = await client.query<{ id: string }>( + `insert into dishes (name, price, allergens, additives) + values ($1, $2, $3, $4) returning id`, + [row.name, row.price, row.allergens, row.additives] ); + savedTranslations.push({ id: inserted.rows[0].id, translations: row.translations }); created++; } } }); + for (const { id, translations } of savedTranslations) { + if (Object.keys(translations).length > 0) { + await replaceTranslations("dish_translations", id, translations); + } + } } return NextResponse.json({ created, updated, skipped }); diff --git a/app/api/dishes/route.ts b/app/api/dishes/route.ts index caf0c7c..d8b533b 100644 --- a/app/api/dishes/route.ts +++ b/app/api/dishes/route.ts @@ -1,12 +1,35 @@ import { NextResponse } from "next/server"; import { query, queryOne } from "@/lib/db"; +import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import type { Dish } from "@/lib/types"; +interface DishRow { + id: string; + name: string; + price: string; + allergens: string[]; + additives: string[]; +} + +async function withTranslations(rows: DishRow[]): Promise { + const translations = await loadTranslationsFor( + "dish_translations", + rows.map((r) => r.id) + ); + return rows.map((r) => ({ + ...r, + price: Number(r.price), + translations: translations.get(r.id) ?? {}, + })); +} + export async function GET() { - const dishes = await query("select * from dishes order by name"); - return NextResponse.json(dishes); + const rows = await query( + "select id, name, price, allergens, additives from dishes order by name" + ); + return NextResponse.json(await withTranslations(rows)); } export async function POST(request: Request) { @@ -17,17 +40,20 @@ export async function POST(request: Request) { await requireRole(["staff", "admin"]); const body = (await request.json()) as { name: string; - name_en: string | null; - name_pl: string | null; price: number; allergens: string[]; additives: string[]; + translations?: Record; }; - const dish = await queryOne( - `insert into dishes (name, name_en, name_pl, price, allergens, additives) - values ($1, $2, $3, $4, $5, $6) returning *`, - [body.name, body.name_en, body.name_pl, body.price, body.allergens, body.additives] - ); + const row = (await queryOne( + `insert into dishes (name, price, allergens, additives) + values ($1, $2, $3, $4) returning id, name, price, allergens, additives`, + [body.name, body.price, body.allergens, body.additives] + ))!; + if (body.translations) { + await replaceTranslations("dish_translations", row.id, body.translations); + } + const [dish] = await withTranslations([row]); return NextResponse.json(dish, { status: 201 }); } catch (err) { const res = authErrorResponse(err); diff --git a/app/api/sections/[key]/route.ts b/app/api/sections/[key]/route.ts index d0e0ab2..a4c44ad 100644 --- a/app/api/sections/[key]/route.ts +++ b/app/api/sections/[key]/route.ts @@ -1,9 +1,17 @@ import { NextResponse } from "next/server"; import { query, queryOne } from "@/lib/db"; +import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import type { Section } from "@/lib/types"; +interface SectionRow { + key: string; + label: string; + time_range: string; + sort: number; +} + export async function PATCH( request: Request, { params }: { params: Promise<{ key: string }> } @@ -15,25 +23,37 @@ export async function PATCH( await requireRole(["staff", "admin"]); const { key } = await params; const body = (await request.json()) as Partial< - Pick - >; + Pick + > & { translations?: Record }; const fields: string[] = []; const values: unknown[] = []; - for (const col of ["label", "label_en", "label_pl", "time_range", "sort"] as const) { + for (const col of ["label", "time_range", "sort"] as const) { if (body[col] !== undefined) { values.push(body[col]); fields.push(`${col} = $${values.length}`); } } - if (fields.length === 0) return NextResponse.json({ error: "empty_update" }, { status: 400 }); - values.push(key); - - const section = await queryOne
( - `update sections set ${fields.join(", ")} where key = $${values.length} returning *`, - values - ); - if (!section) return NextResponse.json({ error: "not_found" }, { status: 404 }); + let row: SectionRow | null; + if (fields.length > 0) { + values.push(key); + row = await queryOne( + `update sections set ${fields.join(", ")} where key = $${values.length} + returning key, label, time_range, sort`, + values + ); + } else { + row = await queryOne( + "select key, label, time_range, sort from sections where key = $1", + [key] + ); + } + if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 }); + if (body.translations) { + await replaceTranslations("section_translations", row.key, body.translations); + } + const translations = await loadTranslationsFor("section_translations", [row.key]); + const section: Section = { ...row, translations: translations.get(row.key) ?? {} }; return NextResponse.json(section); } catch (err) { const res = authErrorResponse(err); diff --git a/app/api/sections/route.ts b/app/api/sections/route.ts index 87763c4..9441515 100644 --- a/app/api/sections/route.ts +++ b/app/api/sections/route.ts @@ -1,12 +1,30 @@ import { NextResponse } from "next/server"; import { query, queryOne } from "@/lib/db"; +import { loadTranslationsFor, replaceTranslations } from "@/lib/db/translations"; import { authErrorResponse, requireRole } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; import type { Section } from "@/lib/types"; +interface SectionRow { + key: string; + label: string; + time_range: string; + sort: number; +} + +async function withTranslations(rows: SectionRow[]): Promise { + const translations = await loadTranslationsFor( + "section_translations", + rows.map((r) => r.key) + ); + return rows.map((r) => ({ ...r, translations: translations.get(r.key) ?? {} })); +} + export async function GET() { - const sections = await query
("select * from sections order by sort"); - return NextResponse.json(sections); + const rows = await query( + "select key, label, time_range, sort from sections order by sort" + ); + return NextResponse.json(await withTranslations(rows)); } export async function POST(request: Request) { @@ -17,19 +35,22 @@ export async function POST(request: Request) { await requireRole(["staff", "admin"]); const body = (await request.json()) as { label: string; - label_en: string | null; - label_pl: string | null; time_range: string; + translations?: Record; }; const maxRow = await queryOne<{ sort: number }>( "select sort from sections order by sort desc limit 1" ); - const section = await queryOne
( - `insert into sections (label, label_en, label_pl, time_range, sort) - values ($1, $2, $3, $4, $5) returning *`, - [body.label, body.label_en, body.label_pl, body.time_range, (maxRow?.sort ?? 0) + 1] - ); + const row = (await queryOne( + `insert into sections (label, time_range, sort) + values ($1, $2, $3) returning key, label, time_range, sort`, + [body.label, body.time_range, (maxRow?.sort ?? 0) + 1] + ))!; + if (body.translations) { + await replaceTranslations("section_translations", row.key, body.translations); + } + const [section] = await withTranslations([row]); return NextResponse.json(section, { status: 201 }); } catch (err) { const res = authErrorResponse(err); diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index 455c53d..8440d6c 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -1,21 +1,31 @@ import { NextResponse } from "next/server"; -import { query } from "@/lib/db"; +import { listActiveLanguageCodes } from "@/lib/db/languages"; +import { replaceTranslations, type TranslationTable } from "@/lib/db/translations"; import { requireRole, UnauthorizedError, ForbiddenError } from "@/lib/auth/session"; import { isSameOrigin } from "@/lib/origin"; -// Übersetzt einen deutschen Text nach Englisch/Polnisch via DeepL und -// schreibt das Ergebnis in die _en/_pl-Spalte der angegebenen Zeile. -// Ohne DEEPL_API_KEY läuft die Route kontrolliert ins Leere (configured: -// false), damit das UI die manuelle Eingabe anbieten kann. +// Übersetzt einen deutschen Text via DeepL in alle aktiven, von DeepL +// unterstützten Sprachen und schreibt das Ergebnis in die passende +// *_translations-Tabelle. Ohne DEEPL_API_KEY läuft die Route kontrolliert +// ins Leere (configured: false), damit das UI die manuelle Eingabe anbietet. -const ALLOWED_TABLES: Record = { - dishes: { idColumn: "id", textColumn: "name" }, - sections: { idColumn: "key", textColumn: "label" }, - allergens: { idColumn: "code", textColumn: "name" }, - additives: { idColumn: "code", textColumn: "name" }, +const ALLOWED_TABLES: Record = { + dishes: { idColumn: "id", translationTable: "dish_translations" }, + sections: { idColumn: "key", translationTable: "section_translations" }, + allergens: { idColumn: "code", translationTable: "allergen_translations" }, + additives: { idColumn: "code", translationTable: "additive_translations" }, }; -async function translateWithDeepL(text: string, targetLang: "EN" | "PL", apiKey: string) { +// Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind +// von DeepL nicht unterstützt und bleiben bei manueller Eingabe. +const DEEPL_TARGET_LANG: Record = { + en: "EN", pl: "PL", fr: "FR", es: "ES", it: "IT", nl: "NL", pt: "PT-PT", + ru: "RU", ja: "JA", zh: "ZH", cs: "CS", da: "DA", el: "EL", fi: "FI", + hu: "HU", id: "ID", ko: "KO", nb: "NB", ro: "RO", sk: "SK", sl: "SL", + sv: "SV", tr: "TR", uk: "UK", bg: "BG", et: "ET", lv: "LV", lt: "LT", +}; + +async function translateWithDeepL(text: string, targetLang: string, apiKey: string) { const host = apiKey.endsWith(":fx") ? "https://api-free.deepl.com" : "https://api.deepl.com"; @@ -71,25 +81,31 @@ export async function POST(request: Request) { return NextResponse.json({ configured: false }); } - try { - const [en, pl] = await Promise.all([ - translateWithDeepL(text, "EN", apiKey), - translateWithDeepL(text, "PL", apiKey), - ]); - if (!en || !pl) throw new Error("Keine Übersetzung erhalten."); + const activeCodes = (await listActiveLanguageCodes()).filter((c) => c !== "de"); + const targets = activeCodes + .map((code) => ({ code, target: DEEPL_TARGET_LANG[code.toLowerCase()] })) + .filter((t): t is { code: string; target: string } => Boolean(t.target)); - if (persist) { - // table/idColumn/textColumn kommen ausschließlich aus dem oben - // geprüften ALLOWED_TABLES-Whitelist, nie aus Nutzereingaben – - // sicher für die String-Interpolation in der Query. - await query( - `update ${table} set ${config.textColumn}_en = $1, ${config.textColumn}_pl = $2 - where ${config.idColumn} = $3`, - [en, pl, id] - ); + try { + const results = await Promise.all( + targets.map(async (t) => ({ + code: t.code, + text: await translateWithDeepL(text, t.target, apiKey), + })) + ); + const translations: Record = {}; + for (const r of results) { + if (r.text) translations[r.code] = r.text; + } + if (Object.keys(translations).length === 0) { + throw new Error("Keine Übersetzung erhalten."); } - return NextResponse.json({ configured: true, en, pl }); + if (persist) { + await replaceTranslations(config.translationTable, id, translations); + } + + return NextResponse.json({ configured: true, translations }); } catch (err) { return NextResponse.json( { error: err instanceof Error ? err.message : "Übersetzung fehlgeschlagen." }, diff --git a/app/page.tsx b/app/page.tsx index 01ac8ca..b83be49 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { query } from "@/lib/db"; import { loadMenuEntries } from "@/lib/db/menu-entries"; +import { loadTranslations } from "@/lib/db/translations"; import { getSettings } from "@/lib/db/settings"; import { addDays, @@ -33,14 +34,39 @@ export default async function Home({ iso: addDays(monday, i), })); - const [sections, entries, allergens, additives, settings] = await Promise.all([ - query
("select * from sections order by sort"), + const [ + sectionRows, + entries, + allergenRows, + additiveRows, + settings, + sectionTranslations, + allergenTranslations, + additiveTranslations, + ] = await Promise.all([ + query>("select key, label, time_range, sort from sections order by sort"), loadMenuEntries(monday, friday), - query("select * from allergens order by sort"), - query("select * from additives order by sort"), + query>("select code, name, sort from allergens order by sort"), + query>("select code, name, sort from additives order by sort"), getSettings(), + loadTranslations("section_translations"), + loadTranslations("allergen_translations"), + loadTranslations("additive_translations"), ]); + const sections: Section[] = sectionRows.map((s) => ({ + ...s, + translations: sectionTranslations.get(s.key) ?? {}, + })); + const allergens: CodeItem[] = allergenRows.map((a) => ({ + ...a, + translations: allergenTranslations.get(a.code) ?? {}, + })); + const additives: CodeItem[] = additiveRows.map((a) => ({ + ...a, + translations: additiveTranslations.get(a.code) ?? {}, + })); + const logo = logoUrl(settings?.logo_path); const labelingDetailed = (settings?.labeling_mode ?? "detailed") !== "notice"; diff --git a/components/CodeListManager.tsx b/components/CodeListManager.tsx index a4b6c25..e975198 100644 --- a/components/CodeListManager.tsx +++ b/components/CodeListManager.tsx @@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react"; import { formatMessage } from "@/lib/i18n/shared"; import { localized } from "@/lib/types"; import type { CodeItem } from "@/lib/types"; -import { useDict, useLocale } from "@/components/i18n/LocaleProvider"; +import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider"; export default function CodeListManager({ table, @@ -18,6 +18,7 @@ export default function CodeListManager({ }) { const dict = useDict(); const locale = useLocale(); + const languages = useLanguages().filter((l) => l.code !== "de"); const title = table === "allergens" ? dict.codeList.allergensTitle : dict.codeList.additivesTitle; const codeLabel = @@ -30,8 +31,7 @@ export default function CodeListManager({ original: string | null; // code vor der Bearbeitung, null = neu code: string; name: string; - nameEn: string; - namePl: string; + translations: Record; } | null>(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -60,8 +60,7 @@ export default function CodeListManager({ const payload = { code, name, - name_en: editing.nameEn.trim() || null, - name_pl: editing.namePl.trim() || null, + translations: editing.translations, }; const res = editing.original @@ -118,7 +117,9 @@ export default function CodeListManager({ const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`); setEditing((prev) => - prev ? { ...prev, nameEn: data.en ?? prev.nameEn, namePl: data.pl ?? prev.namePl } : prev + prev + ? { ...prev, translations: { ...prev.translations, ...(data.translations ?? {}) } } + : prev ); setTranslateState("done"); } catch (err) { @@ -202,7 +203,7 @@ export default function CodeListManager({ type="button" className="btn btn-add" onClick={() => { - setEditing({ original: null, code: "", name: "", nameEn: "", namePl: "" }); + setEditing({ original: null, code: "", name: "", translations: {} }); setTranslateState("idle"); }} > @@ -237,25 +238,26 @@ export default function CodeListManager({ required /> -
-
- - setEditing({ ...editing, nameEn: e.target.value })} - /> + {languages.length > 0 && ( +
+ {languages.map((lang) => ( +
+ + + setEditing({ + ...editing, + translations: { ...editing.translations, [lang.code]: e.target.value }, + }) + } + /> +
+ ))}
-
- - setEditing({ ...editing, namePl: e.target.value })} - /> -
-
- {translateEnabled && ( + )} + {translateEnabled && languages.length > 0 && (
-
-
- - setForm({ ...form, nameEn: e.target.value })} - /> + {languages.length > 0 && ( +
+ {languages.map((lang) => ( +
+ + + setForm({ + ...form, + translations: { ...form.translations, [lang.code]: e.target.value }, + }) + } + /> +
+ ))}
-
- - setForm({ ...form, namePl: e.target.value })} - /> -
-
- {translateEnabled && ( + )} + {translateEnabled && languages.length > 0 && (
-
-
- - setForm({ ...form, labelEn: e.target.value })} - /> + {languages.length > 0 && ( +
+ {languages.map((lang) => ( +
+ + + setForm({ + ...form, + translations: { ...form.translations, [lang.code]: e.target.value }, + }) + } + /> +
+ ))}
-
- - setForm({ ...form, labelPl: e.target.value })} - /> -
-
+ )}