Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion app/Controllers/CmsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,50 @@ public function showPage(Request $request, Response $response, \mysqli $db, arra
return $response;
}

/**
* Index of everything the CMS manages (issue: /admin/cms answered 404).
*
* The three CMS entry points lived only as separate buttons inside the
* settings page, so /admin/cms — the address anyone shortens the others to —
* was a dead end, and a page that settings does not link (the privacy
* policy, any page a locale added) could be reached only by typing its slug.
* The list is read from the database rather than from a fixed menu, so a
* page that exists is a page an administrator can find.
*/
public function index(Request $request, Response $response, \mysqli $db, array $args = []): Response
{
$db->set_charset('utf8mb4');
$currentLocale = \App\Support\I18n::getLocale();

$pages = [];
$stmt = $db->prepare(
'SELECT slug, title, is_active, updated_at FROM cms_pages WHERE locale = ? ORDER BY title'
);
if ($stmt !== false) {
$stmt->bind_param('s', $currentLocale);
if ($stmt->execute()) {
$result = $stmt->get_result();
if ($result instanceof \mysqli_result) {
$pages = $result->fetch_all(MYSQLI_ASSOC);
}
}
$stmt->close();
}

$title = __('Contenuti del sito');

ob_start();
include __DIR__ . '/../Views/cms/index.php';
$content = ob_get_clean();

ob_start();
include __DIR__ . '/../Views/layout.php';
$html = ob_get_clean();

$response->getBody()->write($html);
return $response;
}

public function editHome(Request $request, Response $response, \mysqli $db, array $args): Response
{
// CRITICAL: Set UTF-8 charset to prevent corruption of Greek/Unicode characters
Expand Down Expand Up @@ -555,7 +599,19 @@ public function updateHome(Request $request, Response $response, \mysqli $db, ar
}

if (!empty($errors)) {
$_SESSION['error_message'] = implode('<br>', array_map(fn($e) => htmlspecialchars($e, ENT_QUOTES, 'UTF-8'), $errors));
// Every section above is written only `if (... && empty($errors))`,
// so one invalid field discards the whole submission — including
// edits to sections that have nothing to do with it. The message
// used to name the offending field and stop there, which reads as
// "that one field was ignored" while the visibility someone had just
// switched off quietly came back. Say what actually happened.
//
// Plain text, escaped by the view: the previous version pre-escaped
// each error and joined them with <br>, and the view escapes what it
// is given, so a submission with two problems rendered them as
// "first<br>second" with the tag visible in the middle.
$_SESSION['error_message'] = __('Nessuna modifica è stata salvata: correggi quanto segue e salva di nuovo.')
. ' ' . implode(' · ', $errors);
} else {
\App\Support\ContentCache::homeContentChanged();

Expand Down
8 changes: 8 additions & 0 deletions app/Routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -1008,6 +1008,14 @@
return $controller->delete($request, $response, $args);
})->add(new CsrfMiddleware())->add(new AdminAuthMiddleware());

// Admin CMS index. Declared before /admin/cms/{slug} so the bare address
// lists the content instead of being read as a page slug.
$app->get('/admin/cms[/]', function ($request, $response, $args) use ($app) {
$db = $app->getContainer()->get('db');
$controller = new \App\Controllers\CmsController();
return $controller->index($request, $response, $db, $args);
})->add(new AdminAuthMiddleware());

// Admin CMS routes - Homepage
$app->get('/admin/cms/home', function ($request, $response, $args) use ($app) {
$db = $app->getContainer()->get('db');
Expand Down
45 changes: 42 additions & 3 deletions app/Views/cms/edit-home.php
Original file line number Diff line number Diff line change
Expand Up @@ -1102,11 +1102,44 @@ function saveSectionOrder() {
});
}

// Five sections carry their visibility TWICE on this page: the toggle in
// this list, which writes to the database immediately, and the "Visibile"
// checkbox inside the section's own card, which is written when the form is
// submitted. They used to be independent, so the obvious sequence — switch a
// section off up here, then press Save — sent the card's stale "on" value
// and turned the section straight back on. Keeping them in step means the
// page has one answer to "is this visible", whichever control is used, and
// that the answer follows a failed request back to where it was.
//
// The card's field is addressed by the section key it posts under, so no
// toggle-to-field map has to be maintained by hand. Sections whose
// visibility lives only in this list (hero, the four feature cards) have no
// field and are left alone — their save path does not touch is_active.
const visibilityFieldFor = (toggle) => {
const key = toggle.closest('.section-item')?.dataset.sectionKey;
return key ? document.querySelector('input[name="' + key + '[is_active]"]') : null;
};
const syncVisibilityField = (toggle) => {
const field = visibilityFieldFor(toggle);
if (field) {
field.checked = toggle.checked;
}
};

// Toggle visibility
document.querySelectorAll('.toggle-visibility').forEach(toggle => {
const field = visibilityFieldFor(toggle);
if (field) {
// The other direction never hits the network: the card's checkbox is
// saved with the form, and this list shows what is about to be saved.
field.addEventListener('change', () => {
toggle.checked = field.checked;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}
toggle.addEventListener('change', function() {
const sectionId = parseInt(this.dataset.sectionId);
const isActive = this.checked ? 1 : 0;
syncVisibilityField(this);

fetch(window.BASE_PATH + '/admin/cms/home/toggle-visibility', {
method: 'POST',
Expand All @@ -1126,8 +1159,10 @@ function saveSectionOrder() {
if (data.error || data.code) {
statusEl.textContent = '\u2717 ' + (data.error || <?= json_encode(__("Errore di sicurezza"), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
statusEl.className = 'mt-4 text-sm text-red-600';
// Revert checkbox
// Revert checkbox — and the card's field with it, so a refused
// write never leaves the two controls disagreeing.
toggle.checked = !toggle.checked;
syncVisibilityField(toggle);

// Handle session expiration - reload page to get new CSRF token
if (data.code === 'SESSION_EXPIRED' || data.code === 'CSRF_INVALID') {
Expand All @@ -1147,19 +1182,23 @@ function saveSectionOrder() {
} else {
statusEl.textContent = '\u2717 ' + (data.message || <?= json_encode(__("Errore durante l'aggiornamento"), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
statusEl.className = 'mt-4 text-sm text-red-600';
// Revert checkbox
// Revert checkbox — and the card's field with it, so a refused
// write never leaves the two controls disagreeing.
toggle.checked = !toggle.checked;
syncVisibilityField(toggle);
}
})
.catch(err => {
console.error(err);
statusEl.textContent = '\u2717 ' + <?= json_encode(__("Errore di rete"), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
statusEl.className = 'mt-4 text-sm text-red-600';
// Revert checkbox
// Revert checkbox — and the card's field with it.
this.checked = !this.checked;
syncVisibilityField(this);
});
});
});
}

});
</script>
125 changes: 125 additions & 0 deletions app/Views/cms/index.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php
/**
* Index of the site content an administrator can edit.
*
* @var list<array{slug: string, title: string, is_active: int|string, updated_at: string|null}> $pages
* @var string $currentLocale
*/

use App\Support\HtmlHelper;

// The two fixed destinations. Everything else on this page comes from the
// database, so a page nobody thought to add to a menu is still reachable.
// Full class strings, never interpolated: Tailwind is purged against the
// sources, so a class assembled at runtime has no rule in the stylesheet.
$fixedCards = [
[
'icon' => 'fas fa-home text-blue-600',
'iconWrap' => 'w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center',
'heading' => __('Homepage'),
'description' => __('Modifica i contenuti della homepage: hero, features, CTA e immagine di sfondo'),
'admin' => url('/admin/cms/home'),
'live' => url('/'),
'action' => __('Modifica Homepage'),
],
[
'icon' => 'fas fa-calendar-alt text-purple-600',
'iconWrap' => 'w-10 h-10 rounded-xl bg-purple-100 flex items-center justify-center',
'heading' => __('Eventi'),
'description' => __('Gestisci gli eventi della biblioteca: crea, modifica ed elimina eventi con immagini e descrizioni'),
'admin' => url('/admin/cms/events'),
'live' => route_path('events'),
'action' => __('Gestisci Eventi'),
],
];
?>

<div class="max-w-7xl mx-auto py-6 px-4">
<div class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 flex items-center gap-3">
<i class="fas fa-file-lines text-blue-600"></i>
<?= __("Contenuti del sito") ?>
</h1>
<p class="mt-1 text-sm text-gray-600">
<?= __("Homepage, pagine e eventi del sito pubblico") ?>
</p>
</div>
<a href="<?= htmlspecialchars(url('/admin/settings?tab=cms'), ENT_QUOTES, 'UTF-8') ?>" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 text-sm font-medium transition-colors">
<i class="fas fa-arrow-left"></i>
<?= __("Torna alle Impostazioni") ?>
</a>
</div>
</div>

<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<?php foreach ($fixedCards as $card): ?>
<div class="bg-gray-50 border border-gray-200 rounded-2xl p-6 hover:border-gray-300 transition-colors">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<div class="<?= HtmlHelper::e($card['iconWrap']) ?>">
<i class="<?= HtmlHelper::e($card['icon']) ?>"></i>
</div>
<h3 class="text-lg font-semibold text-gray-900"><?= HtmlHelper::e($card['heading']) ?></h3>
</div>
<p class="text-sm text-gray-600"><?= HtmlHelper::e($card['description']) ?></p>
<div class="mt-3 flex items-center gap-2 text-xs text-gray-500">
<i class="fas fa-link"></i>
<a href="<?= htmlspecialchars($card['live'], ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener noreferrer" class="hover:text-gray-900 underline"><?= __("Visualizza pagina live") ?></a>
</div>
</div>
</div>
<div class="mt-4">
<a href="<?= htmlspecialchars($card['admin'], ENT_QUOTES, 'UTF-8') ?>" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gray-900 text-white text-sm font-semibold hover:bg-gray-700 transition-colors w-full justify-center">
<i class="fas fa-edit"></i>
<?= HtmlHelper::e($card['action']) ?>
</a>
</div>
</div>
<?php endforeach; ?>
</div>

<div class="bg-white rounded-3xl shadow-xl border border-gray-200 mt-6">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-xl font-semibold text-gray-900 flex items-center gap-2">
<i class="fas fa-file-alt text-green-600"></i>
<?= __("Pagine") ?>
</h2>
<p class="text-sm text-gray-600 mt-1">
<?= sprintf(__('Pagine di contenuto nella lingua attiva (%s)'), HtmlHelper::e($currentLocale)) ?>
</p>
</div>
<div class="p-6">
<?php if ($pages === []): ?>
<p class="text-sm text-gray-600"><?= __("Nessuna pagina di contenuto in questa lingua.") ?></p>
<?php else: ?>
<ul class="space-y-3">
<?php foreach ($pages as $page): ?>
<li class="bg-gray-50 rounded-xl p-4 border border-gray-200">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><?= HtmlHelper::e((string) $page['title']) ?></span>
<span class="text-xs text-gray-500 bg-gray-200 px-2 py-1 rounded">/<?= HtmlHelper::e((string) $page['slug']) ?></span>
<?php if (empty($page['is_active'])): ?>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
<span class="text-xs text-gray-600 bg-gray-200 px-2 py-1 rounded"><?= __("Non visibile") ?></span>
<?php endif; ?>
</div>
<div class="flex items-center gap-3">
<a href="<?= htmlspecialchars(url('/' . $page['slug']), ENT_QUOTES, 'UTF-8') ?>" target="_blank" rel="noopener noreferrer" class="text-xs text-gray-500 hover:text-gray-900 underline">
<?= __("Visualizza pagina live") ?>
</a>
<a href="<?= htmlspecialchars(url('/admin/cms/' . $page['slug']), ENT_QUOTES, 'UTF-8') ?>" class="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-gray-900 text-white text-sm font-semibold hover:bg-gray-700 transition-colors">
<i class="fas fa-edit"></i>
<?= __("Modifica") ?>
</a>
</div>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
17 changes: 12 additions & 5 deletions app/Views/frontend/cms-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,20 @@

<section class="cms-page">
<div class="container">
<div class="cms-header">
<h1 class="cms-title"><?= htmlspecialchars($title) ?></h1>
<div class="cms-divider"></div>
</div>

<?php
// The heading lives in the same column as the text it introduces.
// It used to sit directly in the container, one twelfth of the page
// wider on each side, which nobody notices while the theme centres it —
// but the editorial and command layouts align it left, and there it
// started about a hundred pixels to the left of its own first line.
?>
<div class="flex flex-wrap -mx-3 justify-center">
<div class="w-full lg:w-5/6 px-3">
<div class="cms-header">
<h1 class="cms-title"><?= htmlspecialchars($title) ?></h1>
<div class="cms-divider"></div>
</div>

<?php if (!empty($image)): ?>
<img src="<?= htmlspecialchars($image, ENT_QUOTES, 'UTF-8') ?>"
alt="<?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?>"
Expand Down
6 changes: 6 additions & 0 deletions locale/da_DK.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"Nessuna modifica è stata salvata: correggi quanto segue e salva di nuovo.": "Intet blev gemt: ret følgende, og gem igen.",
"Contenuti del sito": "Sidens indhold",
"Homepage, pagine e eventi del sito pubblico": "Forside, sider og arrangementer på det offentlige site",
"Pagine di contenuto nella lingua attiva (%s)": "Indholdssider på det aktive sprog (%s)",
"Nessuna pagina di contenuto in questa lingua.": "Ingen indholdsside på dette sprog.",
"Non visibile": "Ikke synlig",
"L'URL dell'endpoint deve essere un indirizzo https:// completo.": "Endpoint-URL'en skal være en komplet https://-adresse.",
"Per attivare il plugin servono sia l'URL dell'endpoint sia la chiave API.": "For at aktivere pluginnet kræves både endpoint-URL'en og API-nøglen.",
"Importa gli articoli dalla sezione Emeroteca (valore rilevato: \"%s\"). Se il plugin è inattivo, attivalo da Plugins.": "Importér artikler fra Emeroteca-sektionen (fundet værdi: \"%s\"). Hvis pluginnet er inaktivt, skal du aktivere det under Plugins.",
Expand Down
6 changes: 6 additions & 0 deletions locale/de_DE.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"Nessuna modifica è stata salvata: correggi quanto segue e salva di nuovo.": "Es wurde nichts gespeichert: Beheben Sie Folgendes und speichern Sie erneut.",
"Contenuti del sito": "Website-Inhalte",
"Homepage, pagine e eventi del sito pubblico": "Startseite, Seiten und Veranstaltungen der öffentlichen Website",
"Pagine di contenuto nella lingua attiva (%s)": "Inhaltsseiten in der aktiven Sprache (%s)",
"Nessuna pagina di contenuto in questa lingua.": "Keine Inhaltsseite in dieser Sprache.",
"Non visibile": "Nicht sichtbar",
"L'URL dell'endpoint deve essere un indirizzo https:// completo.": "Die Endpoint-URL muss eine vollständige https://-Adresse sein.",
"Per attivare il plugin servono sia l'URL dell'endpoint sia la chiave API.": "Zum Aktivieren des Plugins sind sowohl die Endpoint-URL als auch der API-Schlüssel erforderlich.",
"Importa gli articoli dalla sezione Emeroteca (valore rilevato: \"%s\"). Se il plugin è inattivo, attivalo da Plugins.": "Importieren Sie Artikel über den Bereich Emeroteca (erkannter Wert: „%s“). Ist das Plugin inaktiv, aktivieren Sie es unter Plugins.",
Expand Down
6 changes: 6 additions & 0 deletions locale/en_US.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"Nessuna modifica è stata salvata: correggi quanto segue e salva di nuovo.": "No change was saved: fix the following and save again.",
"Contenuti del sito": "Site content",
"Homepage, pagine e eventi del sito pubblico": "Homepage, pages and events of the public site",
"Pagine di contenuto nella lingua attiva (%s)": "Content pages in the active language (%s)",
"Nessuna pagina di contenuto in questa lingua.": "No content page in this language.",
"Non visibile": "Not visible",
"L'URL dell'endpoint deve essere un indirizzo https:// completo.": "The endpoint URL must be a complete https:// address.",
"Per attivare il plugin servono sia l'URL dell'endpoint sia la chiave API.": "To enable the plugin, both the endpoint URL and the API key are required.",
"Importa gli articoli dalla sezione Emeroteca (valore rilevato: \"%s\"). Se il plugin è inattivo, attivalo da Plugins.": "Import articles from the Emeroteca section (detected value: \"%s\"). If the plugin is inactive, activate it from Plugins.",
Expand Down
6 changes: 6 additions & 0 deletions locale/fr_FR.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"Nessuna modifica è stata salvata: correggi quanto segue e salva di nuovo.": "Aucune modification n’a été enregistrée : corrigez ce qui suit et enregistrez à nouveau.",
"Contenuti del sito": "Contenus du site",
"Homepage, pagine e eventi del sito pubblico": "Page d’accueil, pages et événements du site public",
"Pagine di contenuto nella lingua attiva (%s)": "Pages de contenu dans la langue active (%s)",
"Nessuna pagina di contenuto in questa lingua.": "Aucune page de contenu dans cette langue.",
"Non visibile": "Non visible",
"L'URL dell'endpoint deve essere un indirizzo https:// completo.": "L’URL du point de terminaison doit être une adresse https:// complète.",
"Per attivare il plugin servono sia l'URL dell'endpoint sia la chiave API.": "Pour activer le plugin, l’URL du point de terminaison et la clé API sont toutes deux requises.",
"Importa gli articoli dalla sezione Emeroteca (valore rilevato: \"%s\"). Se il plugin è inattivo, attivalo da Plugins.": "Importez les articles depuis la section Emeroteca (valeur détectée : « %s »). Si le plugin est inactif, activez-le depuis Plugins.",
Expand Down
Loading