Fassung 1.7.0: verlässliche Installation, übersichtliche Verwaltung und bedienbare Bewegung
This commit is contained in:
1 parent
b164c4edfe
commit
495b26896e
55 files changed
+1632
-423
No files matched your search
@@ -20,6 +20,7 @@ use App\Domain\StaleWrite;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
@@ -27,31 +28,53 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
#[Route('/%kant.admin_path%')]
|
||||
final class EditorController extends AbstractController
|
||||
{
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf)
|
||||
public function __construct(private PageRepository $pages, private DocumentValidator $validator, private MenuRepository $menus, private PostRepository $posts, private Theme $theme, private StyleCompiler $styles, private RedirectRepository $redirects, private PartialRepository $partials, private CustomCode $code, private CsrfTokenManagerInterface $csrf, private RequestStack $requests)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_pages', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
#[Route('/posts', name: 'admin_posts', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$pages = $this->pages->listForEditor();
|
||||
$type = 'admin_posts' === $request->attributes->get('_route') ? 'post' : 'page';
|
||||
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||
$status = $request->query->getString('status', 'all');
|
||||
$status = in_array($status, ['all', 'draft', 'published', 'changed', 'trash'], true) ? $status : 'all';
|
||||
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
|
||||
$pages = $result['items'];
|
||||
$parentChoices = [];
|
||||
foreach ($pages as &$page) {
|
||||
$page['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['slug'];
|
||||
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$page['title'] = $page['document']['title'] ?? $page['slug'];
|
||||
$page['depth'] = substr_count((string) $page['path'], '/');
|
||||
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
|
||||
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
|
||||
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
|
||||
}
|
||||
}
|
||||
unset($page);
|
||||
|
||||
return $this->render('admin/pages.html.twig', [
|
||||
'pages' => $pages,
|
||||
'trash' => $this->pages->trash(),
|
||||
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
|
||||
'postLayouts' => PostLayouts::all(),
|
||||
// Which header and footer areas a page can choose from.
|
||||
'pages' => 'trash' === $status ? [] : $pages,
|
||||
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
|
||||
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
|
||||
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
||||
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
|
||||
public function contentChoices(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
|
||||
return $this->json(['items' => $this->pages->choices(
|
||||
mb_substr($request->query->getString('q'), 0, 160),
|
||||
$request->query->getString('type'), $request->query->getBoolean('parents'),
|
||||
)]);
|
||||
}
|
||||
|
||||
#[Route('/pages', name: 'admin_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
@@ -317,8 +340,26 @@ final class EditorController extends AbstractController
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
$request = $this->requests->getCurrentRequest();
|
||||
$post = 'post' === $request?->request->getString('_list_type', $request?->request->getString('type', 'page'));
|
||||
|
||||
return $this->redirectToRoute('admin_pages');
|
||||
if (!$request?->request->has('_list_type')) {
|
||||
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages');
|
||||
}
|
||||
|
||||
$params = [
|
||||
'q' => $request?->request->getString('_list_query', ''),
|
||||
'status' => $request?->request->getString('_list_status', 'all'),
|
||||
'seite' => $request?->request->getInt('_list_page', 1),
|
||||
'limit' => $request?->request->getInt('_list_limit', 20),
|
||||
];
|
||||
foreach (['q' => '', 'status' => 'all', 'seite' => 1, 'limit' => 20] as $key => $default) {
|
||||
if ($params[$key] === $default) {
|
||||
unset($params[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute($post ? 'admin_posts' : 'admin_pages', $params);
|
||||
}
|
||||
|
||||
#[Route('/pages/{id}', name: 'admin_editor', methods: ['GET'])]
|
||||
|
||||
@@ -18,24 +18,24 @@ final class MenuController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$menus = $this->menus->all();
|
||||
$rows = [];
|
||||
foreach (MenuRepository::SLOTS as $slot => $label) {
|
||||
$rows[$slot] = $this->flatten($menus[$slot]['items'] ?? []);
|
||||
}
|
||||
$pages = [];
|
||||
foreach ($this->pages->listForEditor() as $page) {
|
||||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
$pages[] = ['id' => $page['id'], 'path' => $page['path'], 'title' => $document['title'] ?? $page['slug']];
|
||||
$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,
|
||||
'menus' => $menus,
|
||||
'rows' => $rows,
|
||||
'pages' => $pages,
|
||||
'slots' => MenuRepository::SLOTS, 'slot' => $slot, 'slotName' => MenuRepository::SLOTS[$slot],
|
||||
'menu' => $menu, 'rows' => $rows,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ final class MenuController extends AbstractController
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('menus', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
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(
|
||||
@@ -54,10 +54,14 @@ final class MenuController extends AbstractController
|
||||
$request->request->getInt('expectedVersion', -1),
|
||||
);
|
||||
} catch (InvalidMenu|StaleWrite $e) {
|
||||
return $this->back($e->getMessage());
|
||||
return 'json' === $request->getPreferredFormat() ? $this->json(['error' => $e->getMessage()], $e instanceof StaleWrite ? 409 : 422) : $this->back($e->getMessage(), slot: $slot);
|
||||
}
|
||||
|
||||
return $this->back('Das Menü wurde gespeichert.', 'success');
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,10 +126,10 @@ final class MenuController extends AbstractController
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error'): Response
|
||||
private function back(string $message, string $type = 'error', string $slot = 'header'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute('admin_menus');
|
||||
return $this->redirectToRoute('admin_menus', 'header' === $slot ? [] : ['slot' => $slot]);
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,14 @@ final class PageController extends AbstractController
|
||||
$localFonts = [];
|
||||
// The page decides which header and footer it gets - including none.
|
||||
$areas = $this->areas($page);
|
||||
$needsSlider = $needsMotion = false;
|
||||
if (null !== $page) {
|
||||
$theme = $this->theme->load();
|
||||
// Theme first, block styles after, so a block always wins over the site defaults.
|
||||
$sections = $this->sections($page['document'], $areas);
|
||||
$documents = [$page['document'], ...array_values($areas), ...array_values($sections)];
|
||||
$needsSlider = (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'slider'));
|
||||
$needsMotion = (bool) array_filter($documents, fn (array $doc): bool => $this->hasEntrance($doc));
|
||||
$styles = $this->theme->css($theme).$this->scrollTopStyles($this->settings->load(), $theme).$this->styles->compileDocument($page['document']).$this->code->compileDocument($page['document']);
|
||||
foreach ([...array_values($areas), ...array_values($sections)] as $extra) {
|
||||
$styles .= $this->styles->compileDocument($extra).$this->code->compileDocument($extra);
|
||||
@@ -88,7 +92,8 @@ final class PageController extends AbstractController
|
||||
'needsTabs' => $this->hasType($page['document'], 'tabs'),
|
||||
// Ein Slider mit automatischem Weiterblättern ist der zweite Baustein, der
|
||||
// dafür ein Skript braucht - nach demselben Muster wie die Tabs.
|
||||
'needsSlider' => $this->hasAutoplaySlider($page['document']),
|
||||
'needsSlider' => $needsSlider,
|
||||
'needsMotion' => $needsMotion,
|
||||
// Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die
|
||||
// grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat.
|
||||
'needsZoom' => $this->hasZoom($page['document']) || (bool) array_filter(array_values($areas), fn (array $area): bool => $this->hasZoom($area)),
|
||||
@@ -124,7 +129,7 @@ final class PageController extends AbstractController
|
||||
// Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur
|
||||
// dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website.
|
||||
$mitTurnstile = null !== $page && $this->turnstile->isActive() && $this->hasType($page['document'], 'form');
|
||||
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $this->hasAutoplaySlider($page['document']) || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$wantsScript = null !== $page && ($mitTurnstile || $this->hasType($page['document'], 'tabs') || $needsSlider || $needsMotion || $this->hasZoom($page['document']) || [] !== $this->code->scriptsOf($page['document']));
|
||||
$frames = null === $page ? [] : $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections));
|
||||
if ($mitTurnstile) {
|
||||
$frames[] = 'challenges.cloudflare.com';
|
||||
@@ -397,21 +402,19 @@ final class PageController extends AbstractController
|
||||
return $gefunden;
|
||||
}
|
||||
|
||||
/** True when a slider on this page asked to advance by itself. */
|
||||
private function hasAutoplaySlider(array $document): bool
|
||||
private function hasEntrance(array $document): bool
|
||||
{
|
||||
$found = false;
|
||||
$walk = function (array $blocks) use (&$walk, &$found): void {
|
||||
$walk = function (array $blocks) use (&$walk): bool {
|
||||
foreach ($blocks as $block) {
|
||||
if ('slider' === ($block['type'] ?? null) && ($block['props']['autoplay'] ?? 0) >= 3) {
|
||||
$found = true;
|
||||
if ('none' !== ($block['layout']['effects']['entrance'] ?? 'none') || $walk($block['children'] ?? [])) {
|
||||
return true;
|
||||
}
|
||||
$walk($block['children'] ?? []);
|
||||
}
|
||||
};
|
||||
$walk($document['blocks'] ?? []);
|
||||
|
||||
return $found;
|
||||
return false;
|
||||
};
|
||||
|
||||
return $walk($document['blocks'] ?? []);
|
||||
}
|
||||
|
||||
private function hasType(array $document, string $type): bool
|
||||
|
||||
@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
*/
|
||||
final class SystemController extends AbstractController
|
||||
{
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath)
|
||||
public function __construct(private Updater $updater, private Backup $backup, private ArchiveUpdate $archive, private Installation $installation, private string $adminPath, private string $databaseUrl, private string $mediaDirectory, private string $projectDir)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ final class SystemController extends AbstractController
|
||||
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
|
||||
// ist die einzige Stelle, an der das jemand merken kann.
|
||||
'pendingMigrations' => $this->pendingMigrations(),
|
||||
// Actual runtime connection, with credentials excluded from the screen.
|
||||
'database' => $this->databaseFacts(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -86,6 +88,40 @@ final class SystemController extends AbstractController
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Datenbank, wie sie wirklich benutzt wird - samt der Datei, in der das steht.
|
||||
*
|
||||
* @return array{art: string, datei: ?string, bytes: ?int, schreibbar: bool, quelle: string, adresse: string}
|
||||
*/
|
||||
private function databaseFacts(): array
|
||||
{
|
||||
$url = $this->databaseUrl;
|
||||
$datei = null;
|
||||
if (1 === preg_match('#^sqlite:///(.+)$#', $url, $treffer)) {
|
||||
$datei = str_replace(['%kernel.project_dir%', '\\'], [$this->projectDir, '/'], $treffer[1]);
|
||||
}
|
||||
|
||||
return [
|
||||
'art' => null !== $datei ? 'SQLite' : (str_starts_with($url, 'mysql') ? 'MySQL' : 'unbekannt'),
|
||||
'datei' => $datei,
|
||||
'bytes' => null !== $datei && is_file($datei) ? (int) filesize($datei) : null,
|
||||
// Eine Datei, die PHP nicht beschreiben darf, ist der haeufigste Fehler nach einem
|
||||
// Upload per FTP: Dort gehoert sie dem FTP-Benutzer, hier laeuft ein anderer.
|
||||
'schreibbar' => null !== $datei ? (is_writable($datei) && is_writable(\dirname($datei))) : true,
|
||||
'quelle' => $this->environmentSource('DATABASE_URL'),
|
||||
// Bei MySQL steht das Passwort in der Adresse - es wird nicht angezeigt.
|
||||
'adresse' => (string) preg_replace('#://([^:/@]+):[^@]*@#', '://$1:***@', $url),
|
||||
];
|
||||
}
|
||||
|
||||
/** Explicit server values take precedence over the single installation file. */
|
||||
private function environmentSource(string $name): string
|
||||
{
|
||||
$loaded = explode(',', $_SERVER['SYMFONY_DOTENV_VARS'] ?? $_ENV['SYMFONY_DOTENV_VARS'] ?? '');
|
||||
|
||||
return in_array($name, $loaded, true) ? '.env' : 'Server-Umgebung';
|
||||
}
|
||||
|
||||
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
|
||||
private function pendingMigrations(): array
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user