A menu entry picks its anchor from the linked page, link settings of plugins and own menu links get page and PDF choice next to the address, and the post settings offer existing categories and tags to click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
8.1 KiB
PHP
192 lines
8.1 KiB
PHP
<?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\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)
|
|
{
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
$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,
|
|
]);
|
|
}
|
|
|
|
#[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',
|
|
];
|
|
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),
|
|
];
|
|
}
|
|
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 ('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]);
|
|
}
|
|
}
|