Menüs ersetzen die fest verdrahtete Navigation (Arbeitspaket A)
Migration 20260912000500 legt eine Tabelle menu an: ein Menü je Platz (header, footer) und Sprache, Elementbaum als JSON, Version für dieselbe Konflikterkennung wie bei Seiten. Ein Eintrag verweist auf eine Seite, auf ein Sprungziel innerhalb einer Seite oder auf eine Adresse (HTTPS, lokaler Pfad, mailto:, tel:). Seitenverweise speichern die Seiten-ID, nie den Pfad - ein Eintrag folgt damit einer umbenannten Seite. Beim Rendern fallen Einträge zu gelöschten oder unveröffentlichten Seiten heraus, ohne den gespeicherten Baum zu ändern und ohne die übrigen Einträge zu verlieren. Die Verwaltung nutzt eine nummerierte Zeilenliste statt Drag-and-drop und funktioniert damit vollständig ohne JavaScript; Untermenüs in der Ausgabe über details/summary. tests/bootstrap.php verwirft var/cache/test je Lauf. Ohne Debug prüft Symfony weder den kompilierten Container noch die Vorlagen auf Aktualität, was zweimal ein falsches Testergebnis erzeugt hat. Geprüft: 79 PHP-Tests / 285 Assertions unter PHP 8.2.12 und 8.4.22, 11 JavaScript-Modelltests, Lint/Format/Container, Migration zuerst gegen eine Datenbankkopie, wiederholtes Seeding. Keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4a60adf7d7
commit
de9d6e3da8
22 files changed
+765
-19
No files matched your search
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidDocument;
|
||||
use App\Domain\InvalidPage;
|
||||
@@ -18,7 +19,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/admin')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private CsrfTokenManagerInterface $csrf)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -138,7 +139,12 @@ final class EditorController extends AbstractController
|
||||
}
|
||||
$this->validator->validate($document);
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true]);
|
||||
$navigation = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$navigation[$slot] = $this->menus->resolve($menu['items'], (string) $page['path']);
|
||||
}
|
||||
|
||||
return $this->render('page.html.twig', ['page' => [...$page, 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation]);
|
||||
}
|
||||
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion']) || $body['expectedVersion'] < 0) {
|
||||
throw new \InvalidArgumentException();
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Domain\InvalidMenu;
|
||||
use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
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)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$menus = $this->menus->all();
|
||||
$rows = [];
|
||||
foreach (MenuRepository::SLOTS as $slot => $label) {
|
||||
$rows[$slot] = $this->flatten($menus[$slot]['items'] ?? []);
|
||||
}
|
||||
$pages = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$pages[] = ['id' => $page['id'], 'path' => $page['path'], 'title' => $document['title'] ?? $page['slug']];
|
||||
}
|
||||
|
||||
return $this->render('admin/menus.html.twig', [
|
||||
'slots' => MenuRepository::SLOTS,
|
||||
'menus' => $menus,
|
||||
'rows' => $rows,
|
||||
'pages' => $pages,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/menus/{slot}', name: 'admin_menu_save', methods: ['POST'])]
|
||||
public function save(Request $request, string $slot): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
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 $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Menü wurde gespeichert.', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] ?? '',
|
||||
];
|
||||
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'] ?? '')),
|
||||
'level' => max(1, min(MenuRepository::MAX_DEPTH, (int) ($row['level'] ?? 1))),
|
||||
'position' => (int) ($row['position'] ?? 0),
|
||||
];
|
||||
}
|
||||
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 ('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'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_menus');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -10,7 +11,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PageController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private string $siteOrigin)
|
||||
public function __construct(private PageRepository $pages, private MenuRepository $menus, private string $siteOrigin)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ final class PageController extends AbstractController
|
||||
$response = $this->render('page.html.twig', [
|
||||
'page' => $page,
|
||||
'canonical' => rtrim($this->siteOrigin, '/').('home' === $path ? '/' : '/'.$path),
|
||||
'navigation' => $this->navigation($path),
|
||||
]);
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-store');
|
||||
@@ -42,4 +44,17 @@ final class PageController extends AbstractController
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array>>
|
||||
*/
|
||||
private function navigation(string $currentPath): array
|
||||
{
|
||||
$links = [];
|
||||
foreach ($this->menus->all() as $slot => $menu) {
|
||||
$links[$slot] = $this->menus->resolve($menu['items'], $currentPath);
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user