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>
272 lines
13 KiB
PHP
272 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\CustomCode;
|
|
use App\Content\DocumentValidator;
|
|
use App\Content\FontLibrary;
|
|
use App\Content\FormRepository;
|
|
use App\Content\Languages;
|
|
use App\Content\MenuRepository;
|
|
use App\Content\PartialRepository;
|
|
use App\Content\StyleCompiler;
|
|
use App\Content\Theme;
|
|
use App\Content\Translations;
|
|
use App\Domain\InvalidDocument;
|
|
use App\Domain\InvalidPage;
|
|
use App\Domain\StaleWrite;
|
|
use App\Plugin\PluginRegistry;
|
|
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;
|
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
|
|
|
/**
|
|
* Header, footer and reusable sections in the same editor as a page.
|
|
*
|
|
* The editor front end takes its API base from a data attribute, so it works against pages and
|
|
* against areas without a second implementation. Areas have no draft and no publication: they are
|
|
* shared, so a saved change is live. The editor hides the publish button for them.
|
|
*/
|
|
#[Route('/%kant.admin_path%/partials')]
|
|
final class PartialController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private PartialRepository $partials,
|
|
private DocumentValidator $validator,
|
|
private MenuRepository $menus,
|
|
private Theme $theme,
|
|
private StyleCompiler $styles,
|
|
private CustomCode $code,
|
|
private FontLibrary $fonts,
|
|
private CsrfTokenManagerInterface $csrf,
|
|
private PluginRegistry $plugins,
|
|
private FormRepository $forms,
|
|
private Languages $languages,
|
|
) {
|
|
}
|
|
|
|
#[Route('', name: 'admin_partials', methods: ['GET'])]
|
|
public function index(): Response
|
|
{
|
|
// Ein Bereich kennt keinen Entwurf: Speichern ist Veroeffentlichen. Deshalb ist der ganze
|
|
// Bildschirm Sache des Veroeffentlichers, nicht erst der Speicherknopf.
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
$partials = [];
|
|
foreach ($this->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_PUBLISHER');
|
|
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_PUBLISHER');
|
|
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
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
|
|
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
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
|
|
|
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_PUBLISHER');
|
|
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()) > 1100000) {
|
|
return $this->json(['error' => 'Der Bereich ist zu groß (maximal 256 KiB Inhalt, mit Übersetzungen 1 MiB).'], 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, true);
|
|
$language = $body['language'] ?? null;
|
|
|
|
return $this->renderPreview($partial, $document, $request, is_string($language) && $this->languages->isExtra($language) ? $language : null);
|
|
}
|
|
if (!isset($body['expectedVersion']) || !is_int($body['expectedVersion'])) {
|
|
throw new \InvalidArgumentException();
|
|
}
|
|
if (!$this->isGranted('ROLE_ADMIN')) {
|
|
$this->code->assertScriptsUnchanged($document, $partial['document']);
|
|
$this->code->assertHtmlUnchanged($document, $partial['document']);
|
|
}
|
|
$this->partials->save($id, $document, $body['expectedVersion']);
|
|
// Die gespeicherte Fassung zurueck - bereinigter Rich Text steht sofort im Editor.
|
|
$partial['document'] = $this->partials->find($id)['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, Request $request, ?string $language = null): Response
|
|
{
|
|
// Ein Bereich in einer weiteren Sprache (ADR 111): seine Texte, die seiner Sektionen und
|
|
// die Menübeschriftungen in dieser Sprache - wie auf einer übersetzten Seite.
|
|
$localise = static fn (array $doc): array => null === $language ? Translations::strip($doc) : Translations::apply($doc, $language);
|
|
$document = $localise($document);
|
|
$locale = $language ?? $this->languages->main();
|
|
$request->attributes->set('kant_locale', $locale);
|
|
$request->attributes->set('kant_alternates', array_map(fn (string $code): array => [...$this->languages->describe($code), 'locale' => $code, 'href' => '#', 'current' => $code === $locale], $this->languages->all()));
|
|
$theme = $this->theme->load();
|
|
$navigation = [];
|
|
foreach ($this->menus->all() as $slot => $menu) {
|
|
$navigation[$slot] = $this->menus->resolve($menu['items'], '', language: $language);
|
|
}
|
|
$sections = [];
|
|
foreach ($this->partials->referencedSlugs($document) as $slug) {
|
|
$section = $this->partials->resolveSection($slug);
|
|
if (null !== $section) {
|
|
$sections[$slug] = $localise($section['document']);
|
|
}
|
|
}
|
|
|
|
// Wie die Seitenvorschau: eigenes CSS, Sektionsstile und Formulare mit ihren Farben.
|
|
$styles = $this->theme->css($theme).$this->menus->buttonCss($theme['buttons']).$this->styles->compileDocument($document).$this->code->compileDocument($document);
|
|
foreach ($sections as $section) {
|
|
$styles .= $this->styles->compileDocument($section).$this->code->compileDocument($section);
|
|
}
|
|
$forms = [];
|
|
$walk = function (array $blocks) use (&$walk, &$forms, $language): void {
|
|
foreach ($blocks as $block) {
|
|
if ('form' === ($block['type'] ?? null) && '' !== ($block['props']['form'] ?? '')) {
|
|
$form = $this->forms->findBySlug((string) $block['props']['form']);
|
|
if (null !== $form && null !== $language) {
|
|
$form = FormRepository::localise($form, $language);
|
|
}
|
|
$forms[$block['id']] = ['form' => $form, 'sent' => false, 'errors' => [], 'values' => [], 'optionImages' => [], 'blockId' => $block['id'], 'page' => ['path' => ''], 'issued' => 0, 'issuedSignature' => '', 'turnstileKey' => ''];
|
|
if (null !== $form) {
|
|
$styles .= $this->forms->css($form, $theme);
|
|
}
|
|
}
|
|
$walk($block['children'] ?? []);
|
|
}
|
|
};
|
|
$walk($document['blocks'] ?? []);
|
|
foreach ($sections as $section) {
|
|
$walk($section['blocks'] ?? []);
|
|
}
|
|
|
|
return $this->render('page.html.twig', [
|
|
'page' => ['locale' => $locale, 'path' => '', 'type' => 'page', 'slug' => '', 'document' => $document],
|
|
'canonical' => '',
|
|
'editing' => true,
|
|
'navigation' => $navigation,
|
|
'areas' => [],
|
|
'sections' => $sections,
|
|
'postLists' => [],
|
|
'postMeta' => null,
|
|
'needsTabs' => false,
|
|
'forms' => $forms,
|
|
'pageStyles' => $styles,
|
|
'localFonts' => $this->fonts->hasLocal($theme) ? $this->fonts->preload($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(),
|
|
'plugins' => $this->plugins->forEditor(),
|
|
'presets' => $this->plugins->presetsForEditor(),
|
|
// Kopf, Fuß und Sektionen werden wie Seiten übersetzt, nur ohne Adresse und Suchtitel.
|
|
'languages' => [
|
|
'main' => $this->languages->describe($this->languages->main()),
|
|
'extra' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
|
|
'area' => true,
|
|
],
|
|
];
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|