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>
214 lines
9.5 KiB
PHP
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]);
|
|
}
|
|
}
|