From 21ee1bec36594cdabc0e2aaa99958328a6e9f1b2 Mon Sep 17 00:00:00 2001 From: oversu <95a7002b-481b-4ef3-b578-f9c421550aa4@localhost> Date: Mon, 12 Oct 2026 10:42:59 +0200 Subject: [PATCH] =?UTF-8?q?Ajouter=20les=20pages=20personnalis=C3=A9es=20a?= =?UTF-8?q?u=20site=20public?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 11 ++ HISTORIQUE.md | 4 + README.md | 2 +- VERSION | 2 +- app/Controllers/PublicSiteController.php | 36 ++++- app/Controllers/SettingsController.php | 57 ++++--- app/Services/AppSettings.php | 1 + app/Services/PublicPageService.php | 81 ++++++++++ app/Views/evolutions.php | 8 +- app/Views/public_site_layout.php | 36 +++-- app/Views/settings_public_site.php | 183 ++++++++++++++++++++--- docs/08-documents-et-site-public.md | 6 +- migrations/099_public_custom_pages.sql | 13 ++ public/index.php | 1 + resources/lang/en.php | 16 ++ resources/lang/fr.php | 16 ++ tests/run.php | 20 +++ 17 files changed, 428 insertions(+), 65 deletions(-) create mode 100644 app/Services/PublicPageService.php create mode 100644 migrations/099_public_custom_pages.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e2a8bb..0619f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/HISTORIQUE.md b/HISTORIQUE.md index a27c64d..222bee8 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -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. diff --git a/README.md b/README.md index 753e3fa..58ed471 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/VERSION b/VERSION index 78a9d19..0f0d470 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.33 +1.0.0-rc.34 diff --git a/app/Controllers/PublicSiteController.php b/app/Controllers/PublicSiteController.php index 111f590..9b80755 100644 --- a/app/Controllers/PublicSiteController.php +++ b/app/Controllers/PublicSiteController.php @@ -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 diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index e731eb4..f7d3e0d 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -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 diff --git a/app/Services/AppSettings.php b/app/Services/AppSettings.php index d704200..f0c15de 100644 --- a/app/Services/AppSettings.php +++ b/app/Services/AppSettings.php @@ -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 diff --git a/app/Services/PublicPageService.php b/app/Services/PublicPageService.php new file mode 100644 index 0000000..9f0ff2b --- /dev/null +++ b/app/Services/PublicPageService.php @@ -0,0 +1,81 @@ +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); + } +} diff --git a/app/Views/evolutions.php b/app/Views/evolutions.php index bee3864..553c8ee 100644 --- a/app/Views/evolutions.php +++ b/app/Views/evolutions.php @@ -31,10 +31,10 @@
Les réseaux sociaux renseignés par l’association rejoignent la navigation, - avec des liens discrets, sécurisés et accessibles.
+ 1.0.0-rc.34 +Pages personnalisées, contenus longs, panneaux repliables et ordre du menu + réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.