'[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 */ 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]); } }