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>
249 lines
12 KiB
PHP
249 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Controller;
|
||
|
||
use App\Content\Consent;
|
||
use App\Content\DemoContent;
|
||
use App\Content\Languages;
|
||
use App\Content\NotFoundPage;
|
||
use App\Content\PageRepository;
|
||
use App\Content\PostRepository;
|
||
use App\Content\RedirectRepository;
|
||
use App\Content\SiteSettings;
|
||
use App\Content\SocialProfiles;
|
||
use App\Content\Theme;
|
||
use App\Domain\InvalidPage;
|
||
use App\Domain\StaleWrite;
|
||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\HttpFoundation\Response;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
|
||
final class SettingsController extends AbstractController
|
||
{
|
||
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage, private Languages $languages, private PostRepository $posts)
|
||
{
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/settings', name: 'admin_settings', methods: ['GET'])]
|
||
public function index(Request $request): Response
|
||
{
|
||
// Alles hier wirkt sofort auf die Website: 404-Seite und Weiterleitungen sind Sache des
|
||
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
|
||
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
|
||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||
$settings = $this->settings->load();
|
||
$choosable = [];
|
||
// Datenschutz und Impressum werden per Suche gewählt; das Feld zeigt Titel und Adresse der
|
||
// gespeicherten Seite. Eine Adresse ohne Seite bleibt sichtbar, statt still zu verschwinden.
|
||
$legal = ['privacyPath' => null, 'imprintPath' => null];
|
||
foreach (array_keys($legal) as $key) {
|
||
if ('' !== $settings[$key]) {
|
||
$legal[$key] = ['path' => $settings[$key], 'title' => $settings[$key]];
|
||
}
|
||
}
|
||
foreach ($this->pages->listForEditor() as $page) {
|
||
foreach ($legal as $key => $selection) {
|
||
if (null !== $selection && $selection['path'] === $page['path']) {
|
||
$legal[$key]['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['path'];
|
||
}
|
||
}
|
||
// Only a published page can serve as the root; a draft would answer every visit with 404.
|
||
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
|
||
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
|
||
$choosable[] = ['path' => $page['path'], 'title' => $document['title'] ?? $page['path']];
|
||
}
|
||
}
|
||
|
||
return $this->render('admin/settings.html.twig', [
|
||
'settings' => $settings,
|
||
'legalSelection' => $legal,
|
||
'notFoundSelection' => $this->notFoundPage->selected(),
|
||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||
'choosableHome' => $choosable,
|
||
'consentGroups' => Consent::GROUPS,
|
||
'missingLegal' => $this->settings->missingLegalPages(),
|
||
'consentGroupHelp' => Consent::GROUP_HELP,
|
||
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
|
||
'palette' => $this->theme->load()['colors'],
|
||
'socialNetworks' => SocialProfiles::NETWORKS,
|
||
'weekdays' => SiteSettings::DAYS,
|
||
'socialPlaceholders' => SocialProfiles::PLACEHOLDERS,
|
||
'mainLanguage' => $this->languages->describe($this->languages->main()),
|
||
'extraLanguages' => $this->languages->extra(),
|
||
'terms' => [] === $this->languages->extra() ? [] : $this->posts->allTerms(),
|
||
'languageCatalog' => array_map(fn (string $code): array => $this->languages->describe($code), array_values(array_diff(array_keys(Languages::CATALOG), [$this->languages->main()]))),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Die weiteren Sprachen (ADR 111). Eine Sprache dazunehmen ändert an der Website nichts,
|
||
* solange keine Seite übersetzt und veröffentlicht ist; eine Sprache wegnehmen nimmt ihre
|
||
* Seiten vom Netz, lässt die Übersetzungen aber in den Seiten liegen.
|
||
*/
|
||
/** Kategorien, Schlagwörter und Einwilligungshinweis in den weiteren Sprachen (ADR 111). */
|
||
#[Route('/%kant.admin_path%/settings/sprachen/texte', name: 'admin_language_texts_save', methods: ['POST'])]
|
||
public function saveLanguageTexts(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
$all = $request->request->all();
|
||
$this->settings->saveLanguageTexts(is_array($all['termNames'] ?? null) ? $all['termNames'] : [], is_array($all['consentTexts'] ?? null) ? $all['consentTexts'] : []);
|
||
$this->addFlash('success', 'Die Übersetzungen wurden gespeichert.');
|
||
|
||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/settings/sprachen', name: 'admin_languages_save', methods: ['POST'])]
|
||
public function saveLanguages(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
try {
|
||
$this->languages->save(array_values(array_map('strval', (array) ($request->request->all()['languages'] ?? []))));
|
||
} catch (InvalidPage $e) {
|
||
$this->addFlash('error', $e->getMessage());
|
||
|
||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||
}
|
||
$this->addFlash('success', [] === $this->languages->extra() ? 'Die Website ist einsprachig.' : 'Die Sprachen wurden gespeichert. Übersetzt wird im Editor jeder Seite über den Sprachumschalter oben.');
|
||
|
||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'sprachen']);
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/settings/company', name: 'admin_settings_save', methods: ['POST'])]
|
||
public function save(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
try {
|
||
$this->settings->save($request->request->all());
|
||
} catch (InvalidPage $e) {
|
||
return $this->back($e->getMessage());
|
||
}
|
||
|
||
return $this->back('Die Unternehmensdaten wurden gespeichert.', 'success');
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/settings/not-found', name: 'admin_not_found_save', methods: ['POST'])]
|
||
public function saveNotFound(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
try {
|
||
$expectedId = $request->request->getString('expectedId');
|
||
if ('create' === $request->request->getString('action')) {
|
||
$id = $this->notFoundPage->create($expectedId);
|
||
$this->addFlash('success', 'Die 404-Vorlage wurde als Entwurf angelegt und zugewiesen. Nach dem Veröffentlichen ersetzt sie die Standard-404.');
|
||
|
||
return $this->redirectToRoute('admin_editor', ['id' => $id]);
|
||
}
|
||
$id = $request->request->getString('notFoundPageId');
|
||
$this->settings->assignNotFoundPage($id, $expectedId);
|
||
$selection = $this->notFoundPage->selected();
|
||
$message = '' === $id ? 'Die Standard-404 im Website-Design ist wieder aktiv.'
|
||
: ($selection['published'] ? 'Die 404-Seite wurde zugewiesen.' : 'Die Seite wurde zugewiesen. Bis zu ihrer Veröffentlichung erscheint die Standard-404.');
|
||
$this->addFlash('success', $message);
|
||
} catch (InvalidPage|StaleWrite $error) {
|
||
$this->addFlash('error', $error->getMessage());
|
||
}
|
||
|
||
return $this->redirectToRoute('admin_settings', ['_fragment' => 'not-found']);
|
||
}
|
||
|
||
#[Route('/%kant.admin_path%/settings/redirects', name: 'admin_redirect_save', methods: ['POST'])]
|
||
public function saveRedirect(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
$remove = $request->request->getString('remove');
|
||
if ('' !== $remove) {
|
||
$this->redirects->delete($remove);
|
||
|
||
return $this->back('Die Weiterleitung wurde entfernt.', 'success');
|
||
}
|
||
try {
|
||
$this->redirects->save(
|
||
$request->request->getString('from'),
|
||
$request->request->getString('to'),
|
||
$request->request->getInt('status', 301),
|
||
);
|
||
} catch (InvalidPage $e) {
|
||
return $this->back($e->getMessage());
|
||
}
|
||
|
||
return $this->back('Die Weiterleitung wurde gespeichert.', 'success');
|
||
}
|
||
|
||
/**
|
||
* Creates the privacy statement and the imprint, with a skeleton whose gaps are marked.
|
||
*
|
||
* The pages are mandatory, the text is not something a CMS can write: what gets created is the
|
||
* structure with every required item named and marked as still to be filled in.
|
||
*/
|
||
#[Route('/%kant.admin_path%/settings/rechtliches', name: 'admin_legal_pages', methods: ['POST'])]
|
||
public function legalPages(Request $request): Response
|
||
{
|
||
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
|
||
if (!$this->isCsrfTokenValid('settings', $request->request->getString('_csrf_token'))) {
|
||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||
}
|
||
$settings = $this->settings->load();
|
||
$created = [];
|
||
try {
|
||
foreach (DemoContent::LEGAL as $slug => $label) {
|
||
$key = 'datenschutz' === $slug ? 'privacyPath' : 'imprintPath';
|
||
// An existing page is never overwritten - it may already carry the real text.
|
||
$existing = null !== $this->pages->findPublished($slug) || $this->pageExists($slug);
|
||
if (!$existing) {
|
||
$id = $this->pages->createPage($slug, null, 'page', DemoContent::legalPage($slug));
|
||
$page = $this->pages->findForEditor($id);
|
||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||
$created[] = $label;
|
||
}
|
||
$settings[$key] = $slug;
|
||
}
|
||
$this->settings->save([...$settings, 'consentGroups' => $settings['consentGroups'], 'sameAs' => implode('
|
||
', $settings['sameAs'])]);
|
||
} catch (InvalidPage $e) {
|
||
return $this->back($e->getMessage());
|
||
}
|
||
|
||
return $this->back(
|
||
[] === $created
|
||
? 'Die Seiten waren schon vorhanden und sind jetzt verknüpft. Der Inhalt bleibt unverändert.'
|
||
: sprintf('%s angelegt und verknüpft. Beide Seiten sind veröffentlicht und müssen noch ausgefüllt werden – die Pflichtangaben sind darin markiert.', implode(' und ', $created)),
|
||
'success',
|
||
);
|
||
}
|
||
|
||
/** Also finds a page that exists but is not published yet. */
|
||
private function pageExists(string $path): bool
|
||
{
|
||
foreach ($this->pages->listForEditor() as $page) {
|
||
if ($path === $page['path']) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private function back(string $message, string $type = 'error'): Response
|
||
{
|
||
$this->addFlash($type, $message);
|
||
|
||
return $this->redirectToRoute('admin_settings');
|
||
}
|
||
}
|