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

@ -2,6 +2,17 @@
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
## [1.0.0-rc.34] — 2026-10-12
### Pages libres et navigation du site public
- ajout de pages publiques personnalisées en nombre libre depuis les paramètres ;
- publication automatique dans le menu avec une adresse lisible et compatible avec les deux modes de routage ;
- réorganisation par glisser-déposer, complétée par des boutons monter/descendre utilisables sur mobile et au clavier ;
- présentation repliable des pages Accueil, Présentation, Dons, Contact et Mentions légales afin d’alléger l’écran ;
- suppression des troncatures applicatives sur les textes du site public et redimensionnement automatique des zones de saisie ;
- ajout, modification, masquage et suppression contrôlée des pages personnalisées.
## [1.0.0-rc.33] — 2026-10-11
### Réseaux sociaux dans le menu public

View file

@ -10,6 +10,10 @@ L’import depuis Animal Shelter Manager a été conçu comme un parcours pruden
Avant la version 1.0, le projet a fait l’objet d’un nettoyage structurel et d’un découpage de ses fichiers les plus volumineux. La recherche universelle, les salles configurables, les traductions française et anglaise et une suite de contrôles isolés ont complété cette phase de stabilisation.
## `1.0.0-rc.34` — Un site public librement structuré
Les associations peuvent désormais créer autant de pages éditoriales que nécessaire, les publier dans la navigation et choisir leur ordre par glisser-déposer. L’écran d’édition adopte des panneaux repliables et les contenus longs ne sont plus tronqués par Globinours.
## `1.0.0-rc.33` — Réseaux visibles dès la navigation
Le site public reprend désormais dans son menu les réseaux sociaux renseignés par l’association. Les icônes restent discrètes, accessibles et cohérentes avec les liens déjà présentés sur la page Contact.

View file

@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
## État du projet
La candidate actuelle est `1.0.0-rc.33`. La branche 1.0 est gelée fonctionnellement : seuls les défauts bloquants découverts pendant les validations terrain sont corrigés avant `1.0.0`.
La candidate actuelle est `1.0.0-rc.34`. Le site public accepte désormais des pages personnalisées, réordonnables et sans troncature applicative du contenu. La branche 1.0 reste en stabilisation avant `1.0.0`.
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).

View file

@ -1 +1 @@
1.0.0-rc.33
1.0.0-rc.34

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>

View file

@ -14,7 +14,7 @@ Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML
## Activer le site public
Dans **Paramètres → Site public**, activez le site, choisissez son thème et complétez les contenus : accueil, présentation libre, dons, contact et mentions légales. Trois thèmes légers sont fournis.
Dans **Paramètres → Site public**, activez le site, choisissez son thème et complétez les contenus : accueil, présentation libre, dons, contact et mentions légales. Trois thèmes légers sont fournis. Chaque rubrique peut être repliée pour conserver un écran lisible.
Deux modes existent :
@ -32,7 +32,9 @@ Au moins une photo doit être publique pour illustrer correctement la fiche. Les
## Pages publiques
Le menu présente directement les espèces publiées, puis les pages de texte, dons et contact. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet.
Le menu présente directement les espèces publiées, puis les pages de texte, dons et contact. Le bouton **Ajouter une page** permet de créer autant de pages libres que nécessaire. Renseignez un titre, une adresse courte et le contenu, puis réorganisez les pages par glisser-déposer. Les flèches monter/descendre offrent la même fonction sur écran tactile ou au clavier. Une page peut être masquée sans être supprimée.
Les zones de texte acceptent les contenus longs et grandissent avec leur contenu. Le pied de page utilise l’adresse complète, le RNA et le SIRET configurés. La page Contact affiche une carte positionnée sur l’adresse de l’association ; la bibliothèque cartographique est locale mais les tuiles OpenStreetMap nécessitent Internet.
Le téléphone et le courriel bénéficient d’une obfuscation CSS légère contre les robots simples. Cela ne remplace ni une politique antispam ni une adresse dédiée au public.

View file

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS public_site_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE COLLATE NOCASE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_public_site_pages_navigation
ON public_site_pages(enabled, sort_order, title);

View file

@ -16,6 +16,7 @@ require_once __DIR__ . '/../app/Services/BackupService.php';
require_once __DIR__ . '/../app/Services/UpdateService.php';
require_once __DIR__ . '/../app/Services/AppSettings.php';
require_once __DIR__ . '/../app/Services/PublicContentFormatter.php';
require_once __DIR__ . '/../app/Services/PublicPageService.php';
require_once __DIR__ . '/../app/Services/InstallationService.php';
require_once __DIR__ . '/../app/Services/SystemHealthService.php';
require_once __DIR__ . '/../app/Services/NotificationService.php';

View file

@ -260,4 +260,20 @@ return [
'statistics.in_period'=>'during :period',
'statistics.previous_period_comparison'=>'Comparison with the previous period',
'statistics.profiles_help_period'=>'Profiles cover animals admitted during :period, unless stated otherwise.',
'public_site.custom_pages'=>'Custom pages',
'public_site.site_pages'=>'Pages and menu order',
'public_site.site_pages_help'=>'Expand a page to edit it and drag the panels to choose their order in the public menu.',
'public_site.custom_pages_help'=>'Add as many pages as needed and reorder them with drag and drop or the arrow buttons.',
'public_site.add_page'=>'Add a page',
'public_site.new_page'=>'New page',
'public_site.page_title'=>'Page title',
'public_site.page_slug'=>'Page address',
'public_site.page_slug_help'=>'Letters, numbers and hyphens; it is used in the public URL.',
'public_site.page_published'=>'Show this page on the public website',
'public_site.delete_page'=>'Delete this page',
'public_site.drag_page'=>'Drag to reorder',
'public_site.move_up'=>'Move page up',
'public_site.move_down'=>'Move page down',
'public_site.page_invalid'=>'A custom page title or address is invalid or reserved.',
'public_site.page_slug_duplicate'=>'Two custom pages cannot use the same address.',
];

View file

@ -260,4 +260,20 @@ return [
'statistics.in_period'=>'sur :period',
'statistics.previous_period_comparison'=>'Comparaison avec la période précédente',
'statistics.profiles_help_period'=>'Les profils concernent les animaux entrés sur la période :period, sauf mention contraire.',
'public_site.custom_pages'=>'Pages personnalisées',
'public_site.site_pages'=>'Pages et ordre du menu',
'public_site.site_pages_help'=>'Dépliez une page pour la modifier et glissez les blocs pour choisir leur ordre dans le menu public.',
'public_site.custom_pages_help'=>'Ajoutez autant de pages que nécessaire et réorganisez-les par glisser-déposer ou avec les flèches.',
'public_site.add_page'=>'Ajouter une page',
'public_site.new_page'=>'Nouvelle page',
'public_site.page_title'=>'Titre de la page',
'public_site.page_slug'=>'Adresse de la page',
'public_site.page_slug_help'=>'Lettres, chiffres et tirets ; elle sera utilisée dans l’adresse publique.',
'public_site.page_published'=>'Afficher cette page sur le site public',
'public_site.delete_page'=>'Supprimer cette page',
'public_site.drag_page'=>'Glisser pour réorganiser',
'public_site.move_up'=>'Monter la page',
'public_site.move_down'=>'Descendre la page',
'public_site.page_invalid'=>'Le titre ou l’adresse d’une page personnalisée est invalide ou réservé.',
'public_site.page_slug_duplicate'=>'Deux pages personnalisées ne peuvent pas utiliser la même adresse.',
];

View file

@ -34,6 +34,7 @@ foreach (
'SpeciesService',
'LabReferenceService',
'PublicContentFormatter',
'PublicPageService',
'ShelterRoomService',
'GlobalSearchService',
'PrivateMediaService',
@ -306,6 +307,25 @@ try {
$assert(str_contains($layout, 'rel="noopener noreferrer"'), 'Les liens externes ne sont pas protégés');
$assert(!str_contains($contact, '$socialNetworks = ['), 'Le catalogue social est encore dupliqué');
});
$test('Les pages publiques personnalisées conservent ordre et textes longs', function () use ($assert): void {
$longText = str_repeat('Une histoire du refuge. ', 1200);
PublicPageService::saveAll([
['title' => 'Nos partenaires', 'slug' => 'nos-partenaires', 'content' => 'Partenaires', 'enabled' => '1', 'position' => '2'],
['title' => 'Notre histoire', 'slug' => 'notre-histoire', 'content' => $longText, 'enabled' => '1', 'position' => '1'],
]);
$pages = PublicPageService::all(true);
$assert(count($pages) === 2, 'Les pages personnalisées ne sont pas enregistrées');
$assert($pages[0]['slug'] === 'notre-histoire', 'L’ordre choisi n’est pas conservé');
$assert($pages[0]['content'] === trim($longText), 'Le texte long a été tronqué');
$assert(PublicPageService::findBySlug('notre-histoire') !== null, 'La page publiée est introuvable');
$assert(PublicPageService::normalizeSlug('Équipe & refuge') === 'equipe-refuge', 'L’adresse automatique est invalide');
$view = file_get_contents(__DIR__ . '/../app/Views/settings_public_site.php');
$assert(str_contains($view, 'id="public-page-add"'), 'Le bouton d’ajout est absent');
$assert(str_contains($view, 'draggable="true"'), 'Le glisser-déposer est absent');
$assert(str_contains($view, 'public-page-up'), 'Le déplacement accessible est absent');
$controller = file_get_contents(__DIR__ . '/../app/Controllers/PublicSiteController.php');
$assert(str_contains($controller, "'/site/page/'"), 'La route publique personnalisée est absente');
});
$test('Authentification et mot de passe haché', function () use ($db, $assert): void {
$assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé');
$hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT);