Files
KantSites/src/Controller/PartialController.php
T
kantarciandClaude Opus 5 a434e0cefc
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Serve every font from this server and surface open tasks in 1.14.0
Fonts were still reachable from Google in two places. The editor and area
previews were handed `googleFonts` and no `localFonts`, so the screen used
most of the day fetched Lato from fonts.googleapis.com on every open; the
public page fell back to the same link whenever the files were missing.
Neither path remains: the template has no Google branch, `googleFontsUrl()`
is gone, and the policies name no foreign font host - `font-src 'self'`
publicly, `style-src 'self'; font-src 'self'` in administration. Without
local files a site now shows the theme's fallback and says so.

Preloading took the first two weights per family. The Kant-IT theme names
Lato 400, 700 and 900 and sets its headings on 800/900, so the one weight
that carried them loaded after the stylesheet and `font-display:swap`
swapped the heading in plain sight. Every weight of the theme is preloaded
now, latin subset, and the links sit before the stylesheet: measured cold,
all three files finish at 168 ms against a first paint at 292 ms.

Open tasks no longer hide on the screen that owns them. A strip above every
management screen names what needs a button and carries it: a new version
leads to System, missing fonts to Design. Neither asks the network - the
version comes from the daily update-check task, the fonts from a look in
the folder.

System and update was unusable on a phone: `.stack` is a grid whose
automatic column grows to its widest content, and one long database path
pushed the page sideways. It gets `minmax(0, 1fr)`, the backup table stacks
below 620px, and the prose is 45% shorter with the fine print folded away.

The device chips drew the current device with an outline, which lies outside
the box, ran under its neighbour and was clipped by the sidebar - it read as
a cut-off red frame. Used devices are filled in `#9d4e0d` now (5.6:1), the
current one carries a border inside the chip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 00:16:16 +02:00

214 lines
8.9 KiB
PHP

<?php
namespace App\Controller;
use App\Content\CustomCode;
use App\Content\DocumentValidator;
use App\Content\FontLibrary;
use App\Content\MenuRepository;
use App\Content\PartialRepository;
use App\Content\StyleCompiler;
use App\Content\Theme;
use App\Domain\InvalidDocument;
use App\Domain\InvalidPage;
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;
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,
) {
}
#[Route('', name: 'admin_partials', methods: ['GET'])]
public function index(): Response
{
$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_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),
'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(),
];
}
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');
}
}