Ajouter les pages personnalisées au site public

This commit is contained in:
Alexandre NOEL 2026-10-12 10:42:59 +02:00
commit 21ee1bec36
17 changed files with 428 additions and 65 deletions

View file

@ -13,8 +13,8 @@ final class PublicSiteController
if ($path === '/site' || str_starts_with($path, '/site/')) {
return true;
}
return self::usesRoot() &&
in_array(
if (!self::usesRoot()) return false;
if (in_array(
$path,
[
'/',
@ -28,7 +28,9 @@ final class PublicSiteController
'/logo-association',
],
true,
);
)) return true;
return (bool) preg_match('#^/[a-z0-9-]+$#', $path)
&& PublicPageService::findBySlug(trim($path, '/')) !== null;
}
public static function url(string $page = 'home', array $query = []): string
{
@ -43,8 +45,10 @@ final class PublicSiteController
'legal' => $root ? '/mentions-legales' : '/site/mentions-legales',
'privacy' => $root ? '/confidentialite' : '/site/confidentialite',
'logo' => $root ? '/logo-association' : '/site/logo',
'custom' => ($root ? '/' : '/site/page/') . PublicPageService::normalizeSlug((string) ($query['slug'] ?? '')),
default => $root ? '/' : '/site',
};
unset($query['slug']);
return $path . ($query ? '?' . http_build_query($query) : '');
}
public static function dispatch(string $path): void
@ -59,6 +63,10 @@ final class PublicSiteController
self::logo();
return;
}
if (preg_match('#^/site/page/([a-z0-9-]+)$#', $path, $match)) {
self::customPage($match[1]);
return;
}
match ($path) {
'/site', '/site/' => self::home(),
'/site/animaux' => self::animals(),
@ -73,7 +81,7 @@ final class PublicSiteController
}
private static function normalize(string $path): string
{
return match ($path) {
$normalized = match ($path) {
'/' => '/site',
'/animaux' => '/site/animaux',
'/adoption' => '/site/animal',
@ -85,6 +93,11 @@ final class PublicSiteController
'/logo-association' => '/site/logo',
default => $path,
};
if ($normalized === $path && self::usesRoot() && preg_match('#^/([a-z0-9-]+)$#', $path, $match)
&& PublicPageService::findBySlug($match[1]) !== null) {
return '/site/page/' . $match[1];
}
return $normalized;
}
private static function home(): void
{
@ -160,6 +173,20 @@ final class PublicSiteController
'kind' => 'about',
]);
}
private static function customPage(string $slug): void
{
$page = PublicPageService::findBySlug($slug);
if (!$page) {
self::notFound();
return;
}
self::render('public_site_text.php', [
'title' => $page['title'],
'content' => $page['content'],
'kind' => 'custom',
'customPage' => $page,
]);
}
private static function legal(): void
{
self::render('public_site_text.php', [
@ -262,6 +289,7 @@ final class PublicSiteController
extract($vars, EXTR_SKIP);
$settings = AppSettings::all();
$publicSpecies = self::species();
$publicPages = PublicPageService::all(true);
require dirname(__DIR__) . '/Views/public_site_layout.php';
}
private static function notFound(): void

View file

@ -759,6 +759,7 @@ final class SettingsController
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
'images' => $images,
'customPages' => PublicPageService::all(),
]);
}
@ -869,22 +870,30 @@ final class SettingsController
'public_site_routing' => $routing,
'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0',
];
foreach (
[
'public_site_home_title' => 150,
'public_site_name' => 150,
'public_site_home_text' => 5000,
'public_site_about_title' => 150,
'public_site_about_text' => 10000,
'public_site_donation_title' => 150,
'public_site_donation_text' => 10000,
'public_site_contact_text' => 5000,
'public_site_legal_text' => 15000,
'public_site_privacy_text' => 15000,
]
as $key => $max
) {
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max);
$navigation = json_decode((string) ($_POST['public_site_navigation_order'] ?? '[]'), true);
$navigation = is_array($navigation) ? array_values(array_unique(array_filter($navigation, static function ($item): bool {
if (!is_string($item)) return false;
return in_array($item, ['home','animals','about','donation','contact'], true)
|| (bool) preg_match('/^custom:[a-z0-9-]{1,80}$/', $item);
}))) : [];
$submittedPageSlugs = [];
foreach ((array) ($_POST['pages'] ?? []) as $submittedPage) {
if (!is_array($submittedPage) || !empty($submittedPage['delete'])) continue;
$submittedSlug = PublicPageService::normalizeSlug((string) ($submittedPage['slug'] ?? $submittedPage['title'] ?? ''));
if ($submittedSlug !== '') $submittedPageSlugs[] = $submittedSlug;
}
$navigation = array_values(array_filter($navigation, static function (string $item) use ($submittedPageSlugs): bool {
return !str_starts_with($item, 'custom:') || in_array(substr($item, 7), $submittedPageSlugs, true);
}));
foreach (['home','animals','about','donation','contact'] as $required) {
if (!in_array($required, $navigation, true)) $navigation[] = $required;
}
$values['public_site_navigation_order'] = json_encode($navigation, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
foreach (['public_site_home_title','public_site_name','public_site_about_title','public_site_donation_title'] as $key) {
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, 150);
}
foreach (['public_site_home_text','public_site_about_text','public_site_donation_text','public_site_contact_text','public_site_legal_text','public_site_privacy_text'] as $key) {
$values[$key] = trim((string) ($_POST[$key] ?? ''));
}
$customCss = trim((string) ($_POST['public_site_custom_css'] ?? ''));
if (strlen($customCss) > 30000 || preg_match('/(?:<\/style|@import|expression\s*\(|javascript\s*:|behavior\s*:|-moz-binding|url\s*\()/i', $customCss)) {
@ -914,9 +923,19 @@ final class SettingsController
}
$values[$key] = $url;
}
AppSettings::save($values, Auth::id());
AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée');
header('Location: /settings/public-site?saved=1');
$db = DB::pdo();
try {
$db->beginTransaction();
AppSettings::save($values, Auth::id());
PublicPageService::saveAll((array) ($_POST['pages'] ?? []));
$db->commit();
AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée');
header('Location: /settings/public-site?saved=1');
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
$message = $e instanceof InvalidArgumentException ? $e->getMessage() : SecurityService::publicError($e);
header('Location: /settings/public-site?error=' . rawurlencode($message));
}
exit();
}
public static function audit(): void

View file

@ -62,6 +62,7 @@ final class AppSettings
'update_last_error' => '',
'public_site_legal_text' => '',
'public_site_privacy_text' => '',
'public_site_navigation_order' => '["home","animals","about","donation","contact"]',
];
private static ?array $cache = null;
public static function all(): array

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
final class PublicPageService
{
private const RESERVED = [
'site','animaux','adoption','presentation','dons','contact','mentions-legales','confidentialite',
'logo-association','login','logout','setup','dashboard','statistics','agenda','directory','settings','admin',
'accounting','inventory','care-round','animal','litter','documents','media','assets','api',
];
public static function all(bool $enabledOnly = false): array
{
$sql = 'SELECT * FROM public_site_pages' . ($enabledOnly ? ' WHERE enabled=1' : '') .
' ORDER BY sort_order,id';
return DB::pdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
}
public static function findBySlug(string $slug, bool $enabledOnly = true): ?array
{
$slug = self::normalizeSlug($slug);
if ($slug === '') return null;
$stmt = DB::pdo()->prepare(
'SELECT * FROM public_site_pages WHERE slug=:slug' . ($enabledOnly ? ' AND enabled=1' : '') . ' LIMIT 1',
);
$stmt->execute([':slug' => $slug]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
public static function saveAll(array $pages): void
{
$db = DB::pdo();
$pages = array_values(array_filter($pages, 'is_array'));
usort($pages, static fn (array $a, array $b): int => (int) ($a['position'] ?? 0) <=> (int) ($b['position'] ?? 0));
$seenSlugs = [];
$position = 0;
foreach ($pages as $page) {
if (!is_array($page)) continue;
$id = max(0, (int) ($page['id'] ?? 0));
if (!empty($page['delete'])) {
if ($id > 0) $db->prepare('DELETE FROM public_site_pages WHERE id=?')->execute([$id]);
continue;
}
$title = mb_substr(trim((string) ($page['title'] ?? '')), 0, 150);
$slug = self::normalizeSlug((string) ($page['slug'] ?? ''));
if ($slug === '') $slug = self::normalizeSlug($title);
if ($title === '' && $slug === '' && trim((string) ($page['content'] ?? '')) === '') continue;
if ($title === '' || $slug === '' || in_array($slug, self::RESERVED, true)) {
throw new InvalidArgumentException(t('public_site.page_invalid'));
}
if (isset($seenSlugs[$slug])) throw new InvalidArgumentException(t('public_site.page_slug_duplicate'));
$seenSlugs[$slug] = true;
$duplicate = $db->prepare('SELECT id FROM public_site_pages WHERE slug=:slug AND id<>:id LIMIT 1');
$duplicate->execute([':slug' => $slug, ':id' => $id]);
if ($duplicate->fetchColumn()) throw new InvalidArgumentException(t('public_site.page_slug_duplicate'));
$content = trim((string) ($page['content'] ?? ''));
$enabled = !empty($page['enabled']) ? 1 : 0;
if ($id > 0) {
$check = $db->prepare('SELECT 1 FROM public_site_pages WHERE id=?');
$check->execute([$id]);
if (!$check->fetchColumn()) throw new InvalidArgumentException(t('public_site.page_invalid'));
$stmt = $db->prepare('UPDATE public_site_pages SET slug=:slug,title=:title,content=:content,enabled=:enabled,sort_order=:sort,updated_at=CURRENT_TIMESTAMP WHERE id=:id');
$stmt->execute([':slug'=>$slug,':title'=>$title,':content'=>$content,':enabled'=>$enabled,':sort'=>$position,':id'=>$id]);
} else {
$stmt = $db->prepare('INSERT INTO public_site_pages(slug,title,content,enabled,sort_order) VALUES(:slug,:title,:content,:enabled,:sort)');
$stmt->execute([':slug'=>$slug,':title'=>$title,':content'=>$content,':enabled'=>$enabled,':sort'=>$position]);
}
$position++;
}
}
public static function normalizeSlug(string $value): string
{
$value = mb_strtolower(trim($value), 'UTF-8');
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
$value = $ascii === false ? $value : $ascii;
$value = preg_replace('/[^a-z0-9]+/', '-', strtolower($value)) ?? '';
return mb_substr(trim($value, '-'), 0, 80);
}
}

View file

@ -31,10 +31,10 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.33</span>
<h3>Un site public mieux connecté</h3>
<p class="text-muted">Les réseaux sociaux renseignés par l’association rejoignent la navigation,
avec des liens discrets, sécurisés et accessibles.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.34</span>
<h3>Un site public qui s’adapte au refuge</h3>
<p class="text-muted">Pages personnalisées, contenus longs, panneaux repliables et ordre du menu
réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.</p>
</div>
</section>
</div>

View file

@ -9,6 +9,9 @@ $requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_
$activePage = match($requestPath) {
'/','/site' => 'home','/animaux','/site/animaux','/adoption','/site/animal' => 'animals','/presentation','/site/presentation' => 'about','/dons','/site/dons' => 'donation','/contact','/site/contact' => 'contact',default => ''
};
$activeCustomSlug = '';
if (preg_match('#^/site/page/([a-z0-9-]+)$#', $requestPath, $match)) $activeCustomSlug = $match[1];
elseif (PublicSiteController::usesRoot() && preg_match('#^/([a-z0-9-]+)$#', $requestPath, $match)) $activeCustomSlug = $match[1];
$activeSpecies = (string)($selectedSpecies ?? ($animal['species'] ?? ''));
$navClass = static fn (bool$active): string => $active ? ' class="active" aria-current="page"' : '';
$fullAddress = implode(', ', array_filter([$settings['association_address'] ?? '',trim(($settings['association_postal_code'] ?? '') . ' ' . ($settings['association_city'] ?? ''))]));
@ -18,6 +21,16 @@ $socialNetworks = [
'tiktok' => ['TikTok','♪'], 'whatsapp' => ['WhatsApp','☎'], 'linkedin' => ['LinkedIn','in'],
'x' => ['X','𝕏'], 'snapchat' => ['Snapchat','👻'],
];
$customPagesBySlug = [];
foreach (($publicPages ?? []) as $publicPage) $customPagesBySlug[$publicPage['slug']] = $publicPage;
$navigationOrder = json_decode((string)($settings['public_site_navigation_order'] ?? '[]'), true);
$navigationOrder = is_array($navigationOrder) ? $navigationOrder : [];
foreach (['home','animals','about','donation','contact'] as $defaultNavigationItem) {
if (!in_array($defaultNavigationItem, $navigationOrder, true)) $navigationOrder[] = $defaultNavigationItem;
}
foreach (array_keys($customPagesBySlug) as $slug) {
if (!in_array('custom:' . $slug, $navigationOrder, true)) $navigationOrder[] = 'custom:' . $slug;
}
if (!function_exists('public_age')) {
function public_age(array$a): string
{
@ -514,17 +527,18 @@ if (!function_exists('public_age')) {
href="<?= h($siteUrl()) ?>"><?php if (AssociationBrand::path()): ?><img src="<?= h($siteUrl('logo')) ?>"
alt=""><?php endif; ?><span><?= h($siteName) ?></span></a>
<nav class="nav" aria-label="<?= h(t('public_site.navigation')) ?>">
<a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>">
<?= h(t('public_site.home')) ?></a><?php foreach ($publicSpecies as $species): ?>
<a<?= $navClass($activePage === 'animals' && $activeSpecies === $species['code']) ?>
href="<?= h($siteUrl('animals', ['espece' => $species['code']])) ?>">
<?= h(($species['icon'] ?: '🐾') . ' ' . $species['name']) ?></a><?php endforeach; ?><?php if (($settings['public_site_about_enabled'] ?? '0') === '1'): ?>
<a<?= $navClass($activePage === 'about') ?> href="<?= h($siteUrl('about')) ?>">
<?= h($settings['public_site_about_title']) ?></a><?php endif; ?>
<a<?= $navClass($activePage === 'donation') ?> href="<?= h($siteUrl('donation')) ?>">
<?= h(t('public_site.donation')) ?></a>
<a<?= $navClass($activePage === 'contact') ?> href="<?= h($siteUrl('contact')) ?>">
<?= h(t('public_site.contact')) ?></a><?php foreach ($socialNetworks as $network => [$label,$icon]):
<?php foreach ($navigationOrder as $navigationItem): ?>
<?php if ($navigationItem === 'home'): ?><a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>"><?= h(t('public_site.home')) ?></a>
<?php elseif ($navigationItem === 'animals'): foreach ($publicSpecies as $species): ?><a<?= $navClass($activePage === 'animals' && $activeSpecies === $species['code']) ?> href="<?= h($siteUrl('animals', ['espece' => $species['code']])) ?>"><?= h(($species['icon'] ?: '🐾') . ' ' . $species['name']) ?></a><?php endforeach; ?>
<?php elseif ($navigationItem === 'about' && ($settings['public_site_about_enabled'] ?? '0') === '1'): ?><a<?= $navClass($activePage === 'about') ?> href="<?= h($siteUrl('about')) ?>"><?= h($settings['public_site_about_title']) ?></a>
<?php elseif ($navigationItem === 'donation'): ?><a<?= $navClass($activePage === 'donation') ?> href="<?= h($siteUrl('donation')) ?>"><?= h(t('public_site.donation')) ?></a>
<?php elseif ($navigationItem === 'contact'): ?><a<?= $navClass($activePage === 'contact') ?> href="<?= h($siteUrl('contact')) ?>"><?= h(t('public_site.contact')) ?></a>
<?php elseif (str_starts_with((string)$navigationItem, 'custom:')):
$navigationSlug = substr((string)$navigationItem, 7);
$navigationPage = $customPagesBySlug[$navigationSlug] ?? null;
if ($navigationPage): ?><a<?= $navClass($activeCustomSlug === $navigationSlug) ?> href="<?= h($siteUrl('custom', ['slug' => $navigationSlug])) ?>"><?= h($navigationPage['title']) ?></a><?php endif; endif; ?>
<?php endforeach; ?>
<?php foreach ($socialNetworks as $network => [$label,$icon]):
$socialUrl = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
if ($socialUrl === '') continue; ?>
<a class="nav-social-link nav-social-<?= h($network) ?> social-<?= h($network) ?>"

View file

@ -28,6 +28,7 @@
</div>
</div>
<form method="post" action="/settings/public-site/save"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
<input type="hidden" id="public-site-navigation-order" name="public_site_navigation_order" value="<?= h($settings['public_site_navigation_order'] ?? '') ?>">
<div class="card mb-3">
<div class="card-status-top bg-green"></div>
<div class="card-header">
@ -78,10 +79,17 @@
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<div><h3 class="mb-0"><?= h(t('public_site.site_pages')) ?></h3><div class="text-muted small"><?= h(t('public_site.site_pages_help')) ?></div></div>
<button class="btn btn-outline-primary" type="button" id="public-page-add">+ <?= h(t('public_site.add_page')) ?></button>
</div>
<div id="public-pages-sortable" class="mb-3">
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="home" draggable="true" open>
<summary class="card-header gap-2">
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
<h3 class="card-title">🏠 <?= h(t('public_site.home')) ?></h3>
</div>
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
</summary>
<div class="card-body row g-3">
<div class="col-12"><label class="form-label"><?= h(t('common.title')) ?></label><input class="form-control"
name="public_site_home_title" value="<?= h($settings['public_site_home_title']) ?>" required></div>
@ -89,11 +97,14 @@
class="form-control public-rich-text" name="public_site_home_text"
rows="4"><?= h($settings['public_site_home_text']) ?></textarea></div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
</details>
<div class="card mb-2 public-sortable-page" data-page-key="animals" draggable="true"><div class="card-header gap-2"><span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span><h3 class="card-title">🐾 <?= h(t('public_site.animals')) ?></h3><span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button></span></div></div>
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="about" draggable="true">
<summary class="card-header gap-2">
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
<h3 class="card-title">📖 <?= h(t('public_site.free_page')) ?></h3>
</div>
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
</summary>
<div class="card-body row g-3">
<div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox"
name="public_site_about_enabled" value="1"
@ -106,11 +117,13 @@
class="form-control public-rich-text" name="public_site_about_text"
rows="5"><?= h($settings['public_site_about_text']) ?></textarea></div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
</details>
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="donation" draggable="true">
<summary class="card-header gap-2">
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
<h3 class="card-title">💝 <?= h(t('public_site.donation')) ?></h3>
</div>
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
</summary>
<div class="card-body row g-3">
<div class="col-md-4"><label class="form-label"><?= h(t('common.title')) ?></label><input
class="form-control" name="public_site_donation_title"
@ -122,11 +135,13 @@
class="form-control public-rich-text" name="public_site_donation_text"
rows="4"><?= h($settings['public_site_donation_text']) ?></textarea></div>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
</details>
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="contact" draggable="true">
<summary class="card-header gap-2">
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
<h3 class="card-title">✉️ <?= h(t('public_site.contact')) ?></h3>
</div>
<span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span>
</summary>
<div class="card-body row g-3">
<div class="col-12">
<div class="alert alert-info py-2"><?= h(t('public_site.contact_reuse')) ?></div><label
@ -143,11 +158,41 @@
value="<?= h($settings['public_site_' . $network . '_url']) ?>" placeholder="https://..."></div>
<?php endforeach; ?>
</div>
</details>
<?php foreach (($customPages ?? []) as $index => $page): ?>
<details class="card mb-2 public-page-panel public-custom-page public-sortable-page" data-page-key="custom:<?= h($page['slug']) ?>" draggable="true">
<summary class="card-header gap-2">
<span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span>
<h3 class="card-title public-page-summary-title"><?= h($page['title']) ?></h3>
<span class="ms-auto d-flex gap-1 public-page-actions">
<button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" title="<?= h(t('public_site.move_up')) ?>" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button>
<button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" title="<?= h(t('public_site.move_down')) ?>" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button>
<span class="public-page-chevron" aria-hidden="true">⌄</span>
</span>
</summary>
<div class="card-body row g-3">
<input type="hidden" name="pages[<?= $index ?>][id]" value="<?= (int) $page['id'] ?>">
<input class="public-page-position" type="hidden" name="pages[<?= $index ?>][position]" value="<?= $index ?>">
<div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox" name="pages[<?= $index ?>][enabled]" value="1" <?= (int) $page['enabled'] === 1 ? 'checked' : '' ?>><span class="form-check-label"><?= h(t('public_site.page_published')) ?></span></label></div>
<div class="col-md-7"><label class="form-label"><?= h(t('public_site.page_title')) ?></label><input class="form-control public-page-title" name="pages[<?= $index ?>][title]" value="<?= h($page['title']) ?>" required></div>
<div class="col-md-5"><label class="form-label"><?= h(t('public_site.page_slug')) ?></label><input class="form-control public-page-slug" name="pages[<?= $index ?>][slug]" value="<?= h($page['slug']) ?>" required><div class="form-hint"><?= h(t('public_site.page_slug_help')) ?></div></div>
<div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea class="form-control public-rich-text" name="pages[<?= $index ?>][content]" rows="8"><?= h($page['content']) ?></textarea></div>
<div class="col-12"><label class="form-check text-danger"><input class="form-check-input" type="checkbox" name="pages[<?= $index ?>][delete]" value="1"><span class="form-check-label"><?= h(t('public_site.delete_page')) ?></span></label></div>
</div>
</details>
<?php endforeach; ?>
</div>
<div class="card mb-3">
<div class="card-header">
<template id="public-page-template">
<details class="card mb-2 public-page-panel public-custom-page public-sortable-page" data-page-key="custom:" draggable="true" open>
<summary class="card-header gap-2"><span class="public-page-drag" title="<?= h(t('public_site.drag_page')) ?>" aria-hidden="true">⠿</span><h3 class="card-title public-page-summary-title"><?= h(t('public_site.new_page')) ?></h3><span class="ms-auto d-flex gap-1 public-page-actions"><button class="btn btn-sm btn-icon btn-outline-secondary public-page-up" type="button" aria-label="<?= h(t('public_site.move_up')) ?>">↑</button><button class="btn btn-sm btn-icon btn-outline-secondary public-page-down" type="button" aria-label="<?= h(t('public_site.move_down')) ?>">↓</button><span class="public-page-chevron" aria-hidden="true">⌄</span></span></summary>
<div class="card-body row g-3"><input class="public-page-position" type="hidden" name="pages[__INDEX__][position]" value="0"><div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox" name="pages[__INDEX__][enabled]" value="1" checked><span class="form-check-label"><?= h(t('public_site.page_published')) ?></span></label></div><div class="col-md-7"><label class="form-label"><?= h(t('public_site.page_title')) ?></label><input class="form-control public-page-title" name="pages[__INDEX__][title]" required></div><div class="col-md-5"><label class="form-label"><?= h(t('public_site.page_slug')) ?></label><input class="form-control public-page-slug" name="pages[__INDEX__][slug]" required><div class="form-hint"><?= h(t('public_site.page_slug_help')) ?></div></div><div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea class="form-control public-rich-text" name="pages[__INDEX__][content]" rows="8"></textarea></div><div class="col-12"><button class="btn btn-outline-danger public-page-remove" type="button"><?= h(t('public_site.delete_page')) ?></button></div></div>
</details>
</template>
<details class="card mb-3 public-page-panel">
<summary class="card-header">
<h3 class="card-title">⚖️ <?= h(t('public_site.legal_privacy')) ?></h3>
</div>
<span class="public-page-chevron" aria-hidden="true">⌄</span>
</summary>
<div class="card-body row g-3">
<div class="col-md-6"><label class="form-label"><?= h(t('public_site.legal')) ?></label><textarea
class="form-control" name="public_site_legal_text"
@ -157,9 +202,12 @@
rows="16"><?= h(PublicSiteController::privacyText($settings)) ?></textarea></div>
<div class="col-12 form-hint"><?= h(t('public_site.legal_hint')) ?></div>
</div>
</div>
</details>
<div class="text-end mb-4"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
</form>
<style>
.public-page-panel>summary{cursor:pointer;display:flex;align-items:center;list-style:none}.public-page-panel>summary::-webkit-details-marker{display:none}.public-page-chevron{display:inline-flex;transition:transform .2s;font-size:1.25rem}.public-page-panel[open]>summary .public-page-chevron{transform:rotate(180deg)}.public-page-drag{cursor:grab;font-size:1.35rem;color:var(--tblr-secondary);padding:.25rem}.public-custom-page.is-dragging{opacity:.45}.public-custom-page.drag-over{outline:2px dashed var(--tblr-primary);outline-offset:2px}.public-page-actions button{position:relative;z-index:2}
</style>
<script>
let activePublicEditor = null;
const imageLibrary = document.getElementById('public-image-library');
@ -173,15 +221,20 @@ const insertImage = (button) => {
textarea.setRangeText(`![${safeAlt}](${button.dataset.url})\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
textarea.focus();
};
document.querySelectorAll('textarea').forEach((textarea) => {
const enhanceTextarea = (textarea) => {
if (textarea.dataset.autoResize === '1') return;
textarea.dataset.autoResize = '1';
const resize = () => {
textarea.style.height = 'auto';
textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px';
};
textarea.addEventListener('input', resize);
resize();
});
document.querySelectorAll('.public-rich-text').forEach((textarea) => {
};
const enhancePublicEditor = (textarea) => {
enhanceTextarea(textarea);
if (textarea.dataset.richEditor === '1') return;
textarea.dataset.richEditor = '1';
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
const toolbar = document.createElement('div');
toolbar.className = 'btn-list mb-2';
@ -211,7 +264,9 @@ document.querySelectorAll('.public-rich-text').forEach((textarea) => {
});
toolbar.append(imageButton);
textarea.before(toolbar);
});
};
document.querySelectorAll('textarea').forEach(enhanceTextarea);
document.querySelectorAll('.public-rich-text').forEach(enhancePublicEditor);
document.querySelectorAll('.public-image-insert').forEach((button) => {
button.addEventListener('click', () => insertImage(button));
});
@ -243,4 +298,86 @@ document.getElementById('public-image-upload').addEventListener('submit', async
submit.disabled = false;
}
});
const pagesContainer = document.getElementById('public-pages-sortable');
const navigationOrderInput = document.getElementById('public-site-navigation-order');
let publicPageIndex = <?= (int) count($customPages ?? []) ?>;
const refreshPagePositions = () => {
const pages = [...pagesContainer.querySelectorAll('.public-sortable-page')];
pages.forEach((page, position) => {
const input = page.querySelector('.public-page-position');
if (input) input.value = String(position);
page.querySelector('.public-page-up')?.toggleAttribute('disabled', position === 0);
page.querySelector('.public-page-down')?.toggleAttribute('disabled', position === pages.length - 1);
});
navigationOrderInput.value = JSON.stringify(pages.map((page) => page.dataset.pageKey).filter((key) => key && key !== 'custom:'));
};
const slugify = (value) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80);
const wirePage = (page) => {
const title = page.querySelector('.public-page-title');
const slug = page.querySelector('.public-page-slug');
let slugEdited = Boolean(slug?.value);
slug?.addEventListener('input', () => { slugEdited = Boolean(slug.value); });
slug?.addEventListener('input', () => { page.dataset.pageKey = 'custom:' + slugify(slug.value); refreshPagePositions(); });
title?.addEventListener('input', () => {
page.querySelector('.public-page-summary-title').textContent = title.value.trim() || <?= json_encode(t('public_site.new_page'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
if (!slugEdited && slug) {
slug.value = slugify(title.value);
page.dataset.pageKey = 'custom:' + slug.value;
refreshPagePositions();
}
});
page.querySelectorAll('.public-page-actions button').forEach((button) => button.addEventListener('click', (event) => event.stopPropagation()));
page.querySelectorAll('input,textarea,button').forEach((control) => control.addEventListener('mousedown', () => { page.draggable = false; }));
page.querySelector('.public-page-drag')?.addEventListener('mousedown', () => { page.draggable = true; });
page.querySelector('.public-page-up')?.addEventListener('click', () => {
if (page.previousElementSibling) pagesContainer.insertBefore(page, page.previousElementSibling);
refreshPagePositions();
});
page.querySelector('.public-page-down')?.addEventListener('click', () => {
if (page.nextElementSibling) pagesContainer.insertBefore(page.nextElementSibling, page);
refreshPagePositions();
});
page.querySelector('.public-page-remove')?.addEventListener('click', () => { page.remove(); refreshPagePositions(); });
page.querySelectorAll('textarea').forEach(enhanceTextarea);
page.querySelectorAll('.public-rich-text').forEach(enhancePublicEditor);
page.addEventListener('dragstart', (event) => {
if (!event.target.closest('.public-page-drag') && document.activeElement !== page.querySelector('.public-page-drag')) {
// The whole card remains draggable for browser compatibility.
}
page.classList.add('is-dragging');
event.dataTransfer.effectAllowed = 'move';
});
page.addEventListener('dragend', () => {
page.draggable = true;
page.classList.remove('is-dragging');
pagesContainer.querySelectorAll('.drag-over').forEach((item) => item.classList.remove('drag-over'));
refreshPagePositions();
});
};
let savedNavigationOrder = [];
try { savedNavigationOrder = JSON.parse(navigationOrderInput.value || '[]'); } catch (_) {}
const initialPages = [...pagesContainer.querySelectorAll('.public-sortable-page')];
const orderedPages = savedNavigationOrder.map((key) => initialPages.find((item) => item.dataset.pageKey === key)).filter(Boolean);
[...orderedPages, ...initialPages.filter((item) => !orderedPages.includes(item))].forEach((page) => pagesContainer.append(page));
pagesContainer.querySelectorAll('.public-sortable-page').forEach(wirePage);
pagesContainer.addEventListener('dragover', (event) => {
event.preventDefault();
const dragging = pagesContainer.querySelector('.is-dragging');
const target = event.target.closest('.public-sortable-page');
if (!dragging || !target || dragging === target) return;
pagesContainer.querySelectorAll('.drag-over').forEach((item) => item.classList.remove('drag-over'));
target.classList.add('drag-over');
const box = target.getBoundingClientRect();
pagesContainer.insertBefore(dragging, event.clientY < box.top + box.height / 2 ? target : target.nextElementSibling);
});
document.getElementById('public-page-add').addEventListener('click', () => {
const html = document.getElementById('public-page-template').innerHTML.replaceAll('__INDEX__', String(publicPageIndex++));
pagesContainer.insertAdjacentHTML('beforeend', html);
const page = pagesContainer.lastElementChild;
wirePage(page);
refreshPagePositions();
page.querySelector('.public-page-title').focus();
});
refreshPagePositions();
</script>