partials->all() as $partial) { $partial['usedBy'] = 'section' === $partial['kind'] ? $this->partials->usedBy($partial['slug']) : []; $partials[] = $partial; } return $this->render('admin/partials.html.twig', ['partials' => $partials, 'kinds' => PartialRepository::KINDS]); } #[Route('/new', name: 'admin_partial_create', methods: ['POST'])] public function create(Request $request): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } $name = trim($request->request->getString('name')); try { $id = $this->partials->create( $request->request->getString('kind', 'section'), $request->request->getString('slug') ?: $this->slugify($name), $name, // A shared area starts with one empty section, so there is something to click on. ['schemaVersion' => 11, 'title' => '' === $name ? 'Bereich' : $name, 'description' => 'Wird an jeder Stelle gezeigt, die ihn einbindet.', 'blocks' => [ ['id' => 'b_'.bin2hex(random_bytes(8)), 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => []], ]], ); } catch (InvalidPage|InvalidDocument $e) { return $this->back($e->getMessage()); } return $this->redirectToRoute('admin_partial_editor', ['id' => $id]); } #[Route('/{id}/delete', name: 'admin_partial_delete', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])] public function delete(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('partials', $request->request->getString('_csrf_token'))) { return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); } try { $this->partials->delete($id); } catch (InvalidPage $e) { return $this->back($e->getMessage()); } return $this->back('Die Sektion wurde entfernt. Seiten, die sie einbinden, zeigen an dieser Stelle nichts mehr.', 'success'); } #[Route('/{id}', name: 'admin_partial_editor', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])] public function editor(string $id): Response { return $this->render('admin/editor.html.twig', ['partial' => $this->partial($id), 'kinds' => PartialRepository::KINDS]); } #[Route('/{id}/document', name: 'admin_partial_document', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])] public function document(string $id): JsonResponse { return $this->json($this->payload($this->partial($id))); } #[Route('/{id}/draft', name: 'admin_partial_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])] #[Route('/{id}/preview', name: 'admin_partial_preview', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])] public function write(Request $request, string $id): Response { $this->denyAccessUnlessGranted('ROLE_EDITOR'); if (!$this->isCsrfTokenValid('editor', $request->headers->get('X-CSRF-Token'))) { return $this->json(['error' => 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'], 403); } if (strlen($request->getContent()) > 270000) { return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt).'], 413); } $partial = $this->partial($id); try { $body = json_decode($request->getContent(), true, 64, JSON_THROW_ON_ERROR); $document = $body['document'] ?? null; if (!is_array($body) || !is_array($document)) { throw new \InvalidArgumentException(); } if ('admin_partial_preview' === $request->attributes->get('_route')) { $this->validator->validate($document, false); return $this->renderPreview($partial, $document); } if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) { throw new \InvalidArgumentException(); } if (!$this->isGranted('ROLE_ADMIN')) { $this->code->assertScriptsUnchanged($document, $partial['document']); } $this->partials->save($id, $document, $body['expectedVersion']); $partial['document'] = $document; $partial['version'] = $body['expectedVersion'] + 1; return $this->json($this->payload($partial)); } catch (StaleWrite $e) { return $this->json(['error' => $e->getMessage(), 'conflict' => true], 409); } catch (InvalidDocument|InvalidPage $e) { return $this->json(['error' => 'Bitte prüfe die Inhalte: '.$e->getMessage()], 422); } catch (\JsonException|\InvalidArgumentException) { return $this->json(['error' => 'Die Anfrage enthält kein gültiges Dokument.'], 422); } } /** The area on its own, in the shell of a page, so the editor preview looks like the result. */ private function renderPreview(array $partial, array $document): Response { $theme = $this->theme->load(); $navigation = []; foreach ($this->menus->all() as $slot => $menu) { $navigation[$slot] = $this->menus->resolve($menu['items'], ''); } $sections = []; foreach ($this->partials->referencedSlugs($document) as $slug) { $section = $this->partials->resolveSection($slug); if (null !== $section) { $sections[$slug] = $section['document']; } } return $this->render('page.html.twig', [ 'page' => ['locale' => 'de', 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document], 'canonical' => '', 'editing' => true, 'navigation' => $navigation, 'areas' => [], 'sections' => $sections, 'postLists' => [], 'postMeta' => null, 'needsTabs' => false, 'forms' => [], 'pageStyles' => $this->theme->css($theme).$this->styles->compileDocument($document), 'googleFonts' => $this->theme->googleFontsUrl($theme), ]); } private function partial(string $id): array { return $this->partials->find($id) ?? throw $this->createNotFoundException('Bereich nicht gefunden.'); } private function payload(array $partial): array { return [ 'document' => $partial['document'], 'version' => $partial['version'], 'revisionId' => null, 'publishedRevisionId' => null, 'csrfToken' => $this->csrf->getToken('editor')->getValue(), ]; } private function slugify(string $name): string { $slug = strtr(mb_strtolower($name, 'UTF-8'), ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss']); return substr(trim(preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '', '-'), 0, 80); } private function back(string $message, string $type = 'error'): Response { $this->addFlash($type, $message); return $this->redirectToRoute('admin_partials'); } }