Files
KantSites/src/Controller/MenuController.php
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00

214 lines
9.5 KiB
PHP

<?php
namespace App\Controller;
use App\Content\ButtonDesign;
use App\Content\Languages;
use App\Content\MenuButton;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\Theme;
use App\Domain\InvalidMenu;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class MenuController extends AbstractController
{
public function __construct(private MenuRepository $menus, private PageRepository $pages, private Theme $theme, private Languages $languages)
{
}
/**
* Die Sprungziele einer Seite, fuer die Auswahl im Menue: jeder Baustein mit eigenem Anker,
* beschriftet mit seiner ersten Ueberschrift. Wer ein Sprungziel setzt, soll es waehlen und
* nicht wissen muessen, wie es im Editor hiess.
*/
#[Route('/%kant.admin_path%/api/sprungziele/{id}', name: 'admin_menu_anchors', requirements: ['id' => '[A-Za-z0-9_-]{1,64}'], methods: ['GET'])]
public function anchors(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$page = $this->pages->findForEditor($id);
if (null === $page) {
return $this->json(['items' => []], 404);
}
$items = [];
$heading = static function (array $block) use (&$heading): string {
if ('heading' === ($block['type'] ?? null)) {
return trim(html_entity_decode(strip_tags((string) ($block['props']['text'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
foreach ($block['children'] ?? [] as $child) {
$text = $heading($child);
if ('' !== $text) {
return $text;
}
}
return '';
};
$walk = static function (array $blocks) use (&$walk, &$items, $heading): void {
foreach ($blocks as $block) {
if (is_string($block['anchor'] ?? null) && '' !== $block['anchor']) {
$title = $heading($block);
$items[] = ['anchor' => $block['anchor'], 'label' => '' === $title ? '#'.$block['anchor'] : mb_strimwidth($title, 0, 70, '…').' (#'.$block['anchor'].')'];
}
$walk($block['children'] ?? []);
}
};
$walk($page['document']['blocks'] ?? []);
return $this->json(['items' => $items]);
}
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
public function index(Request $request): Response
{
// Ein gespeichertes Menue steht sofort auf der Website - Sache des Veroeffentlichers.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$slot = $request->query->getString('slot', 'header');
$slot = isset(MenuRepository::SLOTS[$slot]) ? $slot : 'header';
$menu = $this->menus->all()[$slot] ?? null;
$theme = $this->theme->load();
$rows = $this->flatten($menu['items'] ?? []);
$selected = [];
foreach ($rows as &$row) {
if ($row['pageId']) {
$selected[$row['pageId']] ??= $this->pages->choices(id: $row['pageId'])[0] ?? ['id' => $row['pageId'], 'title' => 'Nicht mehr verfügbar', 'path' => ''];
}
$row['page'] = $selected[$row['pageId']] ?? null;
}
unset($row);
return $this->render('admin/menus.html.twig', [
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
'menu' => $menu, 'rows' => $rows,
'menuIcons' => \App\Content\DocumentValidator::ICONS,
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
// Fuer die Button-Vorschau und die Farbwahl am Eintrag: dieselben Daten wie im Editor.
'buttonSchema' => ['buttons' => $theme['buttons'], 'colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttonOptions' => ButtonDesign::OPTIONS, 'mobile' => MenuButton::MOBILE],
]);
}
#[Route('/%kant.admin_path%/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
public function save(Request $request, string $slot): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte erneut anmelden.'], 403) : $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', slot: $slot);
}
try {
$this->menus->save(
$slot,
$request->request->getString('name'),
$this->tree($request->request->all('items')),
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidMenu|StaleWrite $e) {
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => $e->getMessage()], $e instanceof StaleWrite ? 409 : 422) : $this->back($e->getMessage(), slot: $slot);
}
if ('json' === $request->getPreferredFormat()) {
return $this->json(['message' => 'Menü gespeichert.', 'version' => $this->menus->all()[$slot]['version']]);
}
return $this->back('Das Menü wurde gespeichert.', 'success', $slot);
}
/**
* Turns the stored tree back into the flat rows the form edits.
*
* @return list<array{label: string, type: string, pageId: string, anchor: string, url: string, level: int}>
*/
private function flatten(array $items, int $level = 1): array
{
$rows = [];
foreach ($items as $item) {
$rows[] = [
'label' => $item['label'], 'type' => $item['type'], 'level' => $level,
'pageId' => $item['pageId'] ?? '', 'anchor' => $item['anchor'] ?? '', 'url' => $item['url'] ?? '',
'newTab' => $item['newTab'] ?? false,
'icon' => $item['icon'] ?? '', 'iconPosition' => $item['iconPosition'] ?? 'before',
'button' => $item['button'] ?? null,
'labels' => $item['labels'] ?? [],
];
array_push($rows, ...$this->flatten($item['children'] ?? [], $level + 1));
}
return $rows;
}
/**
* The form posts a flat, numbered list so it works without JavaScript. Order comes from the
* position field, nesting from the level field: a level-2 row joins the last level-1 row.
* Rows without a label are treated as removed, which is what the empty "add" row is for.
*/
private function tree(array $rows): array
{
$flat = [];
foreach ($rows as $row) {
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
continue;
}
$flat[] = [
'label' => trim((string) $row['label']),
'type' => (string) ($row['type'] ?? 'page'),
'pageId' => (string) ($row['pageId'] ?? ''),
'anchor' => ltrim(trim((string) ($row['anchor'] ?? '')), '#'),
'url' => trim((string) ($row['url'] ?? '')),
'newTab' => '1' === (string) ($row['newTab'] ?? ''),
'icon' => (string) ($row['icon'] ?? ''), 'iconPosition' => (string) ($row['iconPosition'] ?? 'before'),
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
'position' => (int) ($row['position'] ?? 0),
'button' => MenuButton::fromForm($row),
// Je weitere Sprache ein Feld label_<sprache>; leer heißt: wie in der Hauptsprache.
'labels' => array_filter(
array_combine($this->languages->extra(), array_map(static fn (string $code): string => mb_substr(trim((string) ($row['label_'.$code] ?? '')), 0, 80), $this->languages->extra())) ?: [],
static fn (string $text): bool => '' !== $text,
),
];
}
usort($flat, static fn (array $a, array $b): int => $a['position'] <=> $b['position']);
$items = [];
foreach ($flat as $row) {
$item = ['label' => $row['label'], 'type' => $row['type'], 'children' => []];
if ('' !== $row['icon']) {
$item['icon'] = $row['icon'];
$item['iconPosition'] = $row['iconPosition'];
}
if ($row['newTab']) {
$item['newTab'] = true;
}
if (null !== $row['button']) {
$item['button'] = $row['button'];
}
if ([] !== $row['labels']) {
$item['labels'] = $row['labels'];
}
if ('url' === $row['type']) {
$item['url'] = $row['url'];
} else {
$item['pageId'] = $row['pageId'];
$item['anchor'] = $row['anchor'];
}
if (1 === $row['level'] || [] === $items) {
$items[] = $item;
continue;
}
$items[array_key_last($items)]['children'][] = $item;
}
return $items;
}
private function back(string $message, string $type = 'error', string $slot = 'header'): Response
{
$this->addFlash($type, $message);
return $this->redirectToRoute('admin_menus', 'header' === $slot ? [] : ['slot' => $slot]);
}
}