Compare commits

..
4 Commits
Author SHA1 Message Date
florian.wilde 0c05a16f09 Merge fix/locale-fallback-inactive-german: Sprach-Fallback ignoriert deaktiviertes Deutsch 2026-07-20 11:23:30 +02:00
florian.wilde c98a3617a4 fix: Sprach-Fallback ignoriert deaktiviertes Deutsch
getLocale() fiel bei fehlendem/ungültigem Cookie bisher immer fest auf
Deutsch zurück, auch wenn Deutsch im Sprachen-Admin deaktiviert wurde.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
ausblendet, gab es keine Möglichkeit, das über die Oberfläche zu
korrigieren. Fallback nutzt jetzt Deutsch nur noch, wenn es aktiv ist.
2026-07-20 11:22:49 +02:00
florian.wilde ede11e5463 Merge feature/v1.11.0: Speiseplan-Inhalte variabel mehrsprachig 2026-07-20 11:11:09 +02:00
florian.wilde cd8b0fab9f 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.
2026-07-20 11:10:06 +02:00
25 changed files with 741 additions and 292 deletions
+47
View File
@@ -4,6 +4,53 @@ 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.1] – 2026-07-20
### Behoben
- Wurde Deutsch im Sprachen-Admin deaktiviert (z. B. um die App auf nur
Englisch zu reduzieren), blieb die gesamte App trotzdem auf Deutsch
stehen, sowohl im öffentlichen als auch im internen Bereich: `getLocale()`
fiel bei fehlendem/ungültigem Sprach-Cookie bisher immer fest auf
Deutsch zurück, unabhängig davon, ob Deutsch überhaupt noch aktiv war.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
automatisch ausblendet, gab es keine Möglichkeit, das über die
Oberfläche zu korrigieren. Der Fallback nutzt jetzt Deutsch nur noch,
wenn es tatsächlich aktiv ist – sonst die erste aktive Sprache.
## [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
+8 -3
View File
@@ -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: {
+11 -8
View File
@@ -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<string, string> = {};
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 };
+8 -3
View File
@@ -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: {
+11 -8
View File
@@ -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<string, string> = {};
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 };
+24 -8
View File
@@ -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<string, string | null>;
};
const dish = await queryOne<Dish>(
`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<DishRow>(
`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);
+22 -6
View File
@@ -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<Dish>("select * from dishes order by name");
const [dishes, activeCodes] = await Promise.all([
query<DishRow>("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(";"),
]),
+27 -13
View File
@@ -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<string, string>;
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<string, string> = {};
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<string, string> }[] = [];
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 });
+35 -9
View File
@@ -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<Dish[]> {
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<Dish>("select * from dishes order by name");
return NextResponse.json(dishes);
const rows = await query<DishRow>(
"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<string, string | null>;
};
const dish = await queryOne<Dish>(
`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<DishRow>(
`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);
+31 -11
View File
@@ -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<Section, "label" | "label_en" | "label_pl" | "time_range" | "sort">
>;
Pick<Section, "label" | "time_range" | "sort">
> & { translations?: Record<string, string | null> };
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<Section>(
`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<SectionRow>(
`update sections set ${fields.join(", ")} where key = $${values.length}
returning key, label, time_range, sort`,
values
);
} else {
row = await queryOne<SectionRow>(
"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);
+30 -9
View File
@@ -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<Section[]> {
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<Section>("select * from sections order by sort");
return NextResponse.json(sections);
const rows = await query<SectionRow>(
"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<string, string | null>;
};
const maxRow = await queryOne<{ sort: number }>(
"select sort from sections order by sort desc limit 1"
);
const section = await queryOne<Section>(
`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<SectionRow>(
`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);
+43 -27
View File
@@ -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<string, { idColumn: string; textColumn: string }> = {
dishes: { idColumn: "id", textColumn: "name" },
sections: { idColumn: "key", textColumn: "label" },
allergens: { idColumn: "code", textColumn: "name" },
additives: { idColumn: "code", textColumn: "name" },
const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: TranslationTable }> = {
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<string, string> = {
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<string, string> = {};
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." },
+30 -4
View File
@@ -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<Section>("select * from sections order by sort"),
const [
sectionRows,
entries,
allergenRows,
additiveRows,
settings,
sectionTranslations,
allergenTranslations,
additiveTranslations,
] = await Promise.all([
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
loadMenuEntries(monday, friday),
query<CodeItem>("select * from allergens order by sort"),
query<CodeItem>("select * from additives order by sort"),
query<Omit<CodeItem, "translations">>("select code, name, sort from allergens order by sort"),
query<Omit<CodeItem, "translations">>("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";
+28 -27
View File
@@ -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<string, string>;
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div>
<label className="label">{dict.dishManager.nameEnLabel}</label>
<input
className="input"
value={editing.nameEn}
onChange={(e) => setEditing({ ...editing, nameEn: e.target.value })}
/>
{languages.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label">{lang.label}</label>
<input
className="input"
value={editing.translations[lang.code] ?? ""}
onChange={(e) =>
setEditing({
...editing,
translations: { ...editing.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div>
<div>
<label className="label">{dict.dishManager.namePlLabel}</label>
<input
className="input"
value={editing.namePl}
onChange={(e) => setEditing({ ...editing, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
@@ -304,8 +306,7 @@ export default function CodeListManager({
original: item.code,
code: item.code,
name: item.name,
nameEn: item.name_en ?? "",
namePl: item.name_pl ?? "",
translations: { ...item.translations },
});
setTranslateState("idle");
}}
+31 -34
View File
@@ -6,13 +6,12 @@ import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { CodeItem, Dish } from "@/lib/types";
import ChipPicker from "@/components/ChipPicker";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface DishForm {
id: string | null; // null = neues Gericht
name: string;
nameEn: string;
namePl: string;
translations: Record<string, string>;
price: string;
allergens: string[];
additives: string[];
@@ -21,8 +20,7 @@ interface DishForm {
const EMPTY_FORM: DishForm = {
id: null,
name: "",
nameEn: "",
namePl: "",
translations: {},
price: "",
allergens: [],
additives: [],
@@ -35,6 +33,7 @@ export default function DishManager({
}) {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const [dishes, setDishes] = useState<Dish[]>([]);
const [allergens, setAllergens] = useState<CodeItem[]>([]);
const [additives, setAdditives] = useState<CodeItem[]>([]);
@@ -93,11 +92,10 @@ export default function DishManager({
}
const payload = {
name: form.name.trim(),
name_en: form.nameEn.trim() || null,
name_pl: form.namePl.trim() || null,
price,
allergens: form.allergens,
additives: form.additives,
translations: form.translations,
};
const res = form.id
? await fetch(`/api/dishes/${form.id}`, {
@@ -218,7 +216,9 @@ export default function DishManager({
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setForm((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) {
@@ -352,31 +352,29 @@ export default function DishManager({
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="dish-name-en">
{dict.dishManager.nameEnLabel}
</label>
<input
id="dish-name-en"
className="input"
value={form.nameEn}
onChange={(e) => setForm({ ...form, nameEn: e.target.value })}
/>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`dish-name-${lang.code}`}>
{lang.label}
</label>
<input
id={`dish-name-${lang.code}`}
className="input"
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div>
<div>
<label className="label" htmlFor="dish-name-pl">
{dict.dishManager.namePlLabel}
</label>
<input
id="dish-name-pl"
className="input"
value={form.namePl}
onChange={(e) => setForm({ ...form, namePl: e.target.value })}
/>
</div>
</div>
{translateEnabled && (
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
@@ -495,8 +493,7 @@ export default function DishManager({
setForm({
id: dish.id,
name: dish.name,
nameEn: dish.name_en ?? "",
namePl: dish.name_pl ?? "",
translations: { ...dish.translations },
price: String(dish.price).replace(".", ","),
allergens: dish.allergens ?? [],
additives: dish.additives ?? [],
+27 -32
View File
@@ -7,13 +7,12 @@ import { useCallback, useEffect, useState } from "react";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
interface SectionForm {
key: string | null; // null = neue Essenszeit
label: string;
labelEn: string;
labelPl: string;
translations: Record<string, string>;
timeFrom: string;
timeTo: string;
}
@@ -21,8 +20,7 @@ interface SectionForm {
const EMPTY_FORM: SectionForm = {
key: null,
label: "",
labelEn: "",
labelPl: "",
translations: {},
timeFrom: "",
timeTo: "",
};
@@ -35,6 +33,7 @@ function splitTimeRange(range: string): { from: string; to: string } {
export default function SectionManager() {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const [sections, setSections] = useState<Section[]>([]);
const [form, setForm] = useState<SectionForm | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -61,9 +60,8 @@ export default function SectionManager() {
if (!label || !form.timeFrom || !form.timeTo) return;
const payload = {
label,
label_en: form.labelEn.trim() || null,
label_pl: form.labelPl.trim() || null,
time_range: `${form.timeFrom} - ${form.timeTo}`,
translations: form.translations,
};
const res = form.key
@@ -160,30 +158,28 @@ export default function SectionManager() {
required
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="label" htmlFor="section-label-en">
{dict.sectionManager.nameEnLabel}
</label>
<input
id="section-label-en"
className="input"
value={form.labelEn}
onChange={(e) => setForm({ ...form, labelEn: e.target.value })}
/>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`section-label-${lang.code}`}>
{lang.label}
</label>
<input
id={`section-label-${lang.code}`}
className="input"
value={form.translations[lang.code] ?? ""}
onChange={(e) =>
setForm({
...form,
translations: { ...form.translations, [lang.code]: e.target.value },
})
}
/>
</div>
))}
</div>
<div>
<label className="label" htmlFor="section-label-pl">
{dict.sectionManager.namePlLabel}
</label>
<input
id="section-label-pl"
className="input"
value={form.labelPl}
onChange={(e) => setForm({ ...form, labelPl: e.target.value })}
/>
</div>
</div>
)}
<div className="flex gap-3">
<div className="max-w-32">
<label className="label" htmlFor="section-time-from">
@@ -267,8 +263,7 @@ export default function SectionManager() {
setForm({
key: section.key,
label: section.label,
labelEn: section.label_en ?? "",
labelPl: section.label_pl ?? "",
translations: { ...section.translations },
timeFrom: from,
timeTo: to,
});
@@ -0,0 +1,71 @@
-- Teil 2 des "mehrsprachig variabel"-Umbaus: Speiseplan-Inhalte
-- (Gerichte, Essenszeiten, Allergene, Zusatzstoffe) bekommen eine
-- Übersetzungstabelle je Ressource statt fester name_en/name_pl-
-- Spalten (Teil 1, v1.10.0, hat dasselbe schon für die
-- Bedienoberfläche gemacht). Deutsch bleibt die Basis-Spalte
-- (name/label) auf der jeweiligen Haupttabelle.
--
-- WICHTIG bei Deployments mit echten Daten: vor dem Einspielen ein
-- Backup ziehen (siehe DEPLOYMENT.md, Abschnitt "Backup") – Schritt 3
-- entfernt die alten name_en/name_pl-Spalten unwiderruflich, nachdem
-- Schritt 2 ihren Inhalt in die neuen Tabellen übernommen hat.
-- ---------- Schritt 1: neue Tabellen ----------
create table dish_translations (
dish_id uuid not null references dishes(id) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (dish_id, language_code)
);
create table section_translations (
section_key text not null references sections(key) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (section_key, language_code)
);
-- allergens.code/additives.code sind vom Admin umbenennbar (Primary
-- Key), anders als dishes.id/sections.key – "on update cascade" nötig,
-- damit eine Umbenennung des Kürzels nicht die Übersetzungszeilen
-- verwaist.
create table allergen_translations (
allergen_code text not null references allergens(code) on delete cascade on update cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (allergen_code, language_code)
);
create table additive_translations (
additive_code text not null references additives(code) on delete cascade on update cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (additive_code, language_code)
);
-- ---------- Schritt 2: bestehende Übersetzungen übernehmen ----------
insert into dish_translations (dish_id, language_code, name)
select id, 'en', name_en from dishes where name_en is not null and trim(name_en) != '';
insert into dish_translations (dish_id, language_code, name)
select id, 'pl', name_pl from dishes where name_pl is not null and trim(name_pl) != '';
insert into section_translations (section_key, language_code, name)
select key, 'en', label_en from sections where label_en is not null and trim(label_en) != '';
insert into section_translations (section_key, language_code, name)
select key, 'pl', label_pl from sections where label_pl is not null and trim(label_pl) != '';
insert into allergen_translations (allergen_code, language_code, name)
select code, 'en', name_en from allergens where name_en is not null and trim(name_en) != '';
insert into allergen_translations (allergen_code, language_code, name)
select code, 'pl', name_pl from allergens where name_pl is not null and trim(name_pl) != '';
insert into additive_translations (additive_code, language_code, name)
select code, 'en', name_en from additives where name_en is not null and trim(name_en) != '';
insert into additive_translations (additive_code, language_code, name)
select code, 'pl', name_pl from additives where name_pl is not null and trim(name_pl) != '';
-- ---------- Schritt 3: alte Spalten entfernen ----------
alter table dishes drop column name_en, drop column name_pl;
alter table sections drop column label_en, drop column label_pl;
alter table allergens drop column name_en, drop column name_pl;
alter table additives drop column name_en, drop column name_pl;
+71 -32
View File
@@ -16,42 +16,81 @@ delete from allergens;
delete from additives;
-- ---------- Essenszeiten ----------
insert into sections (key, label, label_en, label_pl, time_range, sort) values
('brotzeit', 'Brotzeit', 'Breakfast', 'Śniadanie', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', 'Lunch', 'Obiad', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', 'Lunch (vegetarian)', 'Obiad (wegetariański)', '11:30 - 13:30', 3),
('suppe', 'Suppe', 'Soup', 'Zupa', '11:30 - 13:30', 4);
insert into sections (key, label, time_range, sort) values
('brotzeit', 'Brotzeit', '09:00 - 10:00', 1),
('mittagessen', 'Mittagessen', '11:30 - 13:30', 2),
('mittagessen_veg', 'Mittagessen vegetarisch', '11:30 - 13:30', 3),
('suppe', 'Suppe', '11:30 - 13:30', 4);
insert into section_translations (section_key, language_code, name) values
('brotzeit', 'en', 'Breakfast'),
('brotzeit', 'pl', 'Śniadanie'),
('mittagessen', 'en', 'Lunch'),
('mittagessen', 'pl', 'Obiad'),
('mittagessen_veg', 'en', 'Lunch (vegetarian)'),
('mittagessen_veg', 'pl', 'Obiad (wegetariański)'),
('suppe', 'en', 'Soup'),
('suppe', 'pl', 'Zupa');
-- ---------- Allergene (EU-Kennzeichnung, Buchstaben) ----------
insert into allergens (code, name, name_en, name_pl, sort) values
('A', 'Glutenhaltiges Getreide', 'Cereals containing gluten', 'Zboża zawierające gluten', 1),
('B', 'Krebstiere', 'Crustaceans', 'Skorupiaki', 2),
('C', 'Eier', 'Eggs', 'Jaja', 3),
('D', 'Fisch', 'Fish', 'Ryby', 4),
('E', 'Erdnüsse', 'Peanuts', 'Orzeszki ziemne', 5),
('F', 'Sojabohnen', 'Soybeans', 'Soja', 6),
('G', 'Milch (einschließlich Laktose)', 'Milk (including lactose)', 'Mleko (w tym laktoza)', 7),
('H', 'Schalenfrüchte (Nüsse)', 'Tree nuts', 'Orzechy', 8),
('I', 'Sellerie', 'Celery', 'Seler', 9),
('J', 'Senf', 'Mustard', 'Gorczyca', 10),
('K', 'Sesamsamen', 'Sesame seeds', 'Nasiona sezamu', 11),
('L', 'Schwefeldioxid und Sulfite', 'Sulphur dioxide and sulphites', 'Dwutlenek siarki i siarczyny', 12),
('M', 'Lupinen', 'Lupin', 'Łubin', 13),
('N', 'Weichtiere', 'Molluscs', 'Mięczaki', 14);
insert into allergens (code, name, sort) values
('A', 'Glutenhaltiges Getreide', 1),
('B', 'Krebstiere', 2),
('C', 'Eier', 3),
('D', 'Fisch', 4),
('E', 'Erdnüsse', 5),
('F', 'Sojabohnen', 6),
('G', 'Milch (einschließlich Laktose)', 7),
('H', 'Schalenfrüchte (Nüsse)', 8),
('I', 'Sellerie', 9),
('J', 'Senf', 10),
('K', 'Sesamsamen', 11),
('L', 'Schwefeldioxid und Sulfite', 12),
('M', 'Lupinen', 13),
('N', 'Weichtiere', 14);
insert into allergen_translations (allergen_code, language_code, name) values
('A', 'en', 'Cereals containing gluten'), ('A', 'pl', 'Zboża zawierające gluten'),
('B', 'en', 'Crustaceans'), ('B', 'pl', 'Skorupiaki'),
('C', 'en', 'Eggs'), ('C', 'pl', 'Jaja'),
('D', 'en', 'Fish'), ('D', 'pl', 'Ryby'),
('E', 'en', 'Peanuts'), ('E', 'pl', 'Orzeszki ziemne'),
('F', 'en', 'Soybeans'), ('F', 'pl', 'Soja'),
('G', 'en', 'Milk (including lactose)'), ('G', 'pl', 'Mleko (w tym laktoza)'),
('H', 'en', 'Tree nuts'), ('H', 'pl', 'Orzechy'),
('I', 'en', 'Celery'), ('I', 'pl', 'Seler'),
('J', 'en', 'Mustard'), ('J', 'pl', 'Gorczyca'),
('K', 'en', 'Sesame seeds'), ('K', 'pl', 'Nasiona sezamu'),
('L', 'en', 'Sulphur dioxide and sulphites'), ('L', 'pl', 'Dwutlenek siarki i siarczyny'),
('M', 'en', 'Lupin'), ('M', 'pl', 'Łubin'),
('N', 'en', 'Molluscs'), ('N', 'pl', 'Mięczaki');
-- ---------- Zusatzstoffe (Ziffern) ----------
insert into additives (code, name, name_en, name_pl, sort) values
('1', 'mit Farbstoff', 'with colouring', 'z barwnikiem', 1),
('2', 'mit Konservierungsstoff', 'with preservative', 'z konserwantem', 2),
('3', 'mit Antioxidationsmittel', 'with antioxidant', 'z przeciwutleniaczem', 3),
('4', 'mit Geschmacksverstärker', 'with flavour enhancer', 'ze wzmacniaczem smaku', 4),
('5', 'geschwefelt', 'sulphured', 'siarkowany', 5),
('6', 'geschwärzt', 'blackened', 'barwiony na czarno', 6),
('7', 'gewachst', 'waxed', 'woskowany', 7),
('8', 'mit Phosphat', 'with phosphate', 'z fosforanem', 8),
('9', 'mit Süßungsmittel(n)', 'with sweetener(s)', 'ze słodzikiem/słodzikami', 9),
('10', 'enthält eine Phenylalaninquelle', 'contains a source of phenylalanine', 'zawiera źródło fenyloalaniny', 10),
('11', 'koffeinhaltig', 'caffeinated', 'zawiera kofeinę', 11);
insert into additives (code, name, sort) values
('1', 'mit Farbstoff', 1),
('2', 'mit Konservierungsstoff', 2),
('3', 'mit Antioxidationsmittel', 3),
('4', 'mit Geschmacksverstärker', 4),
('5', 'geschwefelt', 5),
('6', 'geschwärzt', 6),
('7', 'gewachst', 7),
('8', 'mit Phosphat', 8),
('9', 'mit Süßungsmittel(n)', 9),
('10', 'enthält eine Phenylalaninquelle', 10),
('11', 'koffeinhaltig', 11);
insert into additive_translations (additive_code, language_code, name) values
('1', 'en', 'with colouring'), ('1', 'pl', 'z barwnikiem'),
('2', 'en', 'with preservative'), ('2', 'pl', 'z konserwantem'),
('3', 'en', 'with antioxidant'), ('3', 'pl', 'z przeciwutleniaczem'),
('4', 'en', 'with flavour enhancer'), ('4', 'pl', 'ze wzmacniaczem smaku'),
('5', 'en', 'sulphured'), ('5', 'pl', 'siarkowany'),
('6', 'en', 'blackened'), ('6', 'pl', 'barwiony na czarno'),
('7', 'en', 'waxed'), ('7', 'pl', 'woskowany'),
('8', 'en', 'with phosphate'), ('8', 'pl', 'z fosforanem'),
('9', 'en', 'with sweetener(s)'), ('9', 'pl', 'ze słodzikiem/słodzikami'),
('10', 'en', 'contains a source of phenylalanine'), ('10', 'pl', 'zawiera źródło fenyloalaniny'),
('11', 'en', 'caffeinated'), ('11', 'pl', 'zawiera kofeinę');
-- ---------- Testgerichte ----------
insert into dishes (name, price) values
+68 -39
View File
@@ -1,39 +1,70 @@
import "server-only";
import { query, queryOne, withTransaction } from "@/lib/db";
import { query, queryOne } from "@/lib/db";
import {
loadTranslations,
loadTranslationsFor,
replaceTranslations,
type TranslationTable,
} from "./translations";
import type { CodeItem } from "@/lib/types";
// "allergens"/"additives" sind identisch aufgebaute Tabellen (Kürzel +
// Bezeichnung in DE/EN/PL). Tabellenname kommt nie aus Nutzereingaben,
// Bezeichnung in Deutsch, Übersetzungen in der jeweiligen
// *_translations-Tabelle). Tabellenname kommt nie aus Nutzereingaben,
// sondern immer aus dem literal getypten Parameter unten – sicher für
// String-Interpolation in der Query.
export type CodeTable = "allergens" | "additives";
const TRANSLATION_TABLE: Record<CodeTable, TranslationTable> = {
allergens: "allergen_translations",
additives: "additive_translations",
};
interface CodeRow {
code: string;
name: string;
sort: number;
}
export async function listCodes(table: CodeTable): Promise<CodeItem[]> {
return query<CodeItem>(`select * from ${table} order by sort`);
const rows = await query<CodeRow>(`select code, name, sort from ${table} order by sort`);
const translations = await loadTranslations(TRANSLATION_TABLE[table]);
return rows.map((r) => ({ ...r, translations: translations.get(r.code) ?? {} }));
}
export async function createCode(
table: CodeTable,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
input: { code: string; name: string; translations?: Record<string, string | null> }
): Promise<CodeItem> {
const maxRow = await queryOne<{ sort: number }>(
`select sort from ${table} order by sort desc limit 1`
);
return (await queryOne<CodeItem>(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5) returning *`,
[input.code, input.name, input.name_en, input.name_pl, (maxRow?.sort ?? 0) + 1]
const row = (await queryOne<CodeRow>(
`insert into ${table} (code, name, sort) values ($1, $2, $3) returning code, name, sort`,
[input.code, input.name, (maxRow?.sort ?? 0) + 1]
))!;
if (input.translations) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
}
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
return { ...row, translations: translations.get(row.code) ?? {} };
}
export async function updateCode(
table: CodeTable,
originalCode: string,
input: { code: string; name: string; name_en: string | null; name_pl: string | null }
input: { code: string; name: string; translations?: Record<string, string | null> }
): Promise<CodeItem | null> {
return queryOne<CodeItem>(
`update ${table} set code = $1, name = $2, name_en = $3, name_pl = $4 where code = $5 returning *`,
[input.code, input.name, input.name_en, input.name_pl, originalCode]
const row = await queryOne<CodeRow>(
`update ${table} set code = $1, name = $2 where code = $3 returning code, name, sort`,
[input.code, input.name, originalCode]
);
if (!row) return null;
if (input.translations) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, input.translations);
}
const translations = await loadTranslationsFor(TRANSLATION_TABLE[table], [row.code]);
return { ...row, translations: translations.get(row.code) ?? {} };
}
export async function deleteCode(table: CodeTable, code: string): Promise<void> {
@@ -43,8 +74,7 @@ export async function deleteCode(table: CodeTable, code: string): Promise<void>
export interface CodeImportRow {
code: string;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
}
/** CSV-Import: legt neue Kürzel an, bestehende (gleicher Code) werden
@@ -54,32 +84,31 @@ export async function importCodes(
table: CodeTable,
rows: CodeImportRow[]
): Promise<{ created: number; updated: number }> {
return withTransaction(async (client) => {
const existing = await client.query<{ code: string }>(`select code from ${table}`);
const known = new Set(existing.rows.map((r) => r.code));
const maxRow = await client.query<{ sort: number }>(
`select coalesce(max(sort), 0) as sort from ${table}`
);
let nextSort = (maxRow.rows[0]?.sort ?? 0) + 1;
let created = 0;
let updated = 0;
const existing = await query<{ code: string }>(`select code from ${table}`);
const known = new Set(existing.map((r) => r.code));
const maxRow = await queryOne<{ sort: number }>(
`select coalesce(max(sort), 0) as sort from ${table}`
);
let nextSort = (maxRow?.sort ?? 0) + 1;
let created = 0;
let updated = 0;
for (const row of rows) {
if (known.has(row.code)) {
await client.query(
`update ${table} set name = $1, name_en = $2, name_pl = $3 where code = $4`,
[row.name, row.name_en, row.name_pl, row.code]
);
updated++;
} else {
await client.query(
`insert into ${table} (code, name, name_en, name_pl, sort) values ($1, $2, $3, $4, $5)`,
[row.code, row.name, row.name_en, row.name_pl, nextSort++]
);
known.add(row.code);
created++;
}
for (const row of rows) {
if (known.has(row.code)) {
await query(`update ${table} set name = $1 where code = $2`, [row.name, row.code]);
updated++;
} else {
await query(`insert into ${table} (code, name, sort) values ($1, $2, $3)`, [
row.code,
row.name,
nextSort++,
]);
known.add(row.code);
created++;
}
return { created, updated };
});
if (Object.keys(row.translations).length > 0) {
await replaceTranslations(TRANSLATION_TABLE[table], row.code, row.translations);
}
}
return { created, updated };
}
+6 -5
View File
@@ -1,5 +1,6 @@
import "server-only";
import { query } from "@/lib/db";
import { loadTranslationsFor } from "./translations";
import type { MenuEntry } from "@/lib/types";
interface MenuEntryRow {
@@ -9,8 +10,6 @@ interface MenuEntryRow {
position: number;
dish_id: string;
name: string;
name_en: string | null;
name_pl: string | null;
price: string;
allergens: string[];
additives: string[];
@@ -22,7 +21,7 @@ interface MenuEntryRow {
export async function loadMenuEntries(fromISO: string, toISO: string): Promise<MenuEntry[]> {
const rows = await query<MenuEntryRow>(
`select me.id, me.menu_date, me.section_key, me.position,
d.id as dish_id, d.name, d.name_en, d.name_pl, d.price, d.allergens, d.additives
d.id as dish_id, d.name, d.price, d.allergens, d.additives
from menu_entries me
join dishes d on d.id = me.dish_id
where me.menu_date between $1 and $2
@@ -30,6 +29,9 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
[fromISO, toISO]
);
const dishIds = [...new Set(rows.map((r) => r.dish_id))];
const translations = await loadTranslationsFor("dish_translations", dishIds);
return rows.map((r) => ({
id: r.id,
menu_date: r.menu_date,
@@ -38,8 +40,7 @@ export async function loadMenuEntries(fromISO: string, toISO: string): Promise<M
dish: {
id: r.dish_id,
name: r.name,
name_en: r.name_en,
name_pl: r.name_pl,
translations: translations.get(r.dish_id) ?? {},
price: Number(r.price),
allergens: r.allergens,
additives: r.additives,
+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]
);
}
}
-4
View File
@@ -93,8 +93,6 @@ const de = {
newDish: "Neues Gericht",
editDish: "Gericht bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Name (Englisch)",
namePlLabel: "Name (Polnisch)",
autoTranslate: "Automatisch übersetzen",
translating: "Übersetze…",
translated: "Übersetzt.",
@@ -150,8 +148,6 @@ const de = {
newSection: "Neue Essenszeit",
editSection: "Essenszeit bearbeiten",
nameLabel: "Bezeichnung",
nameEnLabel: "Bezeichnung (Englisch)",
namePlLabel: "Bezeichnung (Polnisch)",
timeFrom: "Von",
timeTo: "Bis",
moveUp: "Nach oben",
+5 -2
View File
@@ -14,12 +14,15 @@ export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven
* Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv →
* Basis-Sprache. */
* Basis-Sprache, sofern die noch aktiv ist – sonst die erste aktive
* Sprache (z. B. wenn Deutsch im Admin-Bereich deaktiviert wurde). */
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get(LOCALE_COOKIE)?.value;
const active = await listActiveLanguageCodes();
return isLocale(value, active) ? value : DEFAULT_LOCALE;
if (isLocale(value, active)) return value;
if (active.includes(DEFAULT_LOCALE)) return DEFAULT_LOCALE;
return active[0] ?? DEFAULT_LOCALE;
}
/** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der
+21 -7
View File
@@ -12,8 +12,9 @@ export interface Profile {
export interface Section {
key: string;
label: string;
label_en: string | null;
label_pl: string | null;
// Sprachcode (z. B. "en", "fr") -> übersetzter Name. Enthält nie "de" -
// Deutsch ist immer die "label"-Spalte selbst.
translations: Record<string, string>;
time_range: string;
sort: number;
}
@@ -21,8 +22,7 @@ export interface Section {
export interface Dish {
id: string;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
price: number;
allergens: string[];
additives: string[];
@@ -39,8 +39,7 @@ export interface MenuEntry {
export interface CodeItem {
code: string;
name: string;
name_en: string | null;
name_pl: string | null;
translations: Record<string, string>;
sort: number;
}
@@ -53,10 +52,25 @@ export interface DishSuggestion {
/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */
export function localized(
item: { name?: string; label?: string; name_en?: string | null; name_pl?: string | null; label_en?: string | null; label_pl?: string | null },
item: {
name?: string;
label?: string;
// Neue, dynamische Form (Gerichte/Essenszeiten/Allergene/
// Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text.
translations?: Record<string, string>;
// Ältere, feste Form – wird u. a. noch für den Hinweistext in den
// Design-Einstellungen genutzt (settings.labeling_notice_en/_pl),
// die kein Übersetzungstabellen-Konzept haben.
name_en?: string | null;
name_pl?: string | null;
label_en?: string | null;
label_pl?: string | null;
},
locale: string
): string {
const de = item.name ?? item.label ?? "";
if (locale === "de") return de;
if (item.translations) return item.translations[locale] || de;
if (locale === "en") return item.name_en || item.label_en || de;
if (locale === "pl") return item.name_pl || item.label_pl || de;
return de;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.10.0",
"version": "1.11.1",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",