Ajouter les pages personnalisées au site public
This commit is contained in:
parent
9e05196440
commit
21ee1bec36
17 changed files with 428 additions and 65 deletions
11
CHANGELOG.md
11
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/).
|
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
|
## [1.0.0-rc.33] — 2026-10-11
|
||||||
|
|
||||||
### Réseaux sociaux dans le menu public
|
### Réseaux sociaux dans le menu public
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## `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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ L’option `--apply` ne doit être utilisée qu’après lecture des rapports. L
|
||||||
|
|
||||||
## État du projet
|
## É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).
|
Le contrôle de stabilisation est disponible dans [docs/AUDIT-1.0.md](docs/AUDIT-1.0.md).
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
1.0.0-rc.33
|
1.0.0-rc.34
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ final class PublicSiteController
|
||||||
if ($path === '/site' || str_starts_with($path, '/site/')) {
|
if ($path === '/site' || str_starts_with($path, '/site/')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return self::usesRoot() &&
|
if (!self::usesRoot()) return false;
|
||||||
in_array(
|
if (in_array(
|
||||||
$path,
|
$path,
|
||||||
[
|
[
|
||||||
'/',
|
'/',
|
||||||
|
|
@ -28,7 +28,9 @@ final class PublicSiteController
|
||||||
'/logo-association',
|
'/logo-association',
|
||||||
],
|
],
|
||||||
true,
|
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
|
public static function url(string $page = 'home', array $query = []): string
|
||||||
{
|
{
|
||||||
|
|
@ -43,8 +45,10 @@ final class PublicSiteController
|
||||||
'legal' => $root ? '/mentions-legales' : '/site/mentions-legales',
|
'legal' => $root ? '/mentions-legales' : '/site/mentions-legales',
|
||||||
'privacy' => $root ? '/confidentialite' : '/site/confidentialite',
|
'privacy' => $root ? '/confidentialite' : '/site/confidentialite',
|
||||||
'logo' => $root ? '/logo-association' : '/site/logo',
|
'logo' => $root ? '/logo-association' : '/site/logo',
|
||||||
|
'custom' => ($root ? '/' : '/site/page/') . PublicPageService::normalizeSlug((string) ($query['slug'] ?? '')),
|
||||||
default => $root ? '/' : '/site',
|
default => $root ? '/' : '/site',
|
||||||
};
|
};
|
||||||
|
unset($query['slug']);
|
||||||
return $path . ($query ? '?' . http_build_query($query) : '');
|
return $path . ($query ? '?' . http_build_query($query) : '');
|
||||||
}
|
}
|
||||||
public static function dispatch(string $path): void
|
public static function dispatch(string $path): void
|
||||||
|
|
@ -59,6 +63,10 @@ final class PublicSiteController
|
||||||
self::logo();
|
self::logo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (preg_match('#^/site/page/([a-z0-9-]+)$#', $path, $match)) {
|
||||||
|
self::customPage($match[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
match ($path) {
|
match ($path) {
|
||||||
'/site', '/site/' => self::home(),
|
'/site', '/site/' => self::home(),
|
||||||
'/site/animaux' => self::animals(),
|
'/site/animaux' => self::animals(),
|
||||||
|
|
@ -73,7 +81,7 @@ final class PublicSiteController
|
||||||
}
|
}
|
||||||
private static function normalize(string $path): string
|
private static function normalize(string $path): string
|
||||||
{
|
{
|
||||||
return match ($path) {
|
$normalized = match ($path) {
|
||||||
'/' => '/site',
|
'/' => '/site',
|
||||||
'/animaux' => '/site/animaux',
|
'/animaux' => '/site/animaux',
|
||||||
'/adoption' => '/site/animal',
|
'/adoption' => '/site/animal',
|
||||||
|
|
@ -85,6 +93,11 @@ final class PublicSiteController
|
||||||
'/logo-association' => '/site/logo',
|
'/logo-association' => '/site/logo',
|
||||||
default => $path,
|
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
|
private static function home(): void
|
||||||
{
|
{
|
||||||
|
|
@ -160,6 +173,20 @@ final class PublicSiteController
|
||||||
'kind' => 'about',
|
'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
|
private static function legal(): void
|
||||||
{
|
{
|
||||||
self::render('public_site_text.php', [
|
self::render('public_site_text.php', [
|
||||||
|
|
@ -262,6 +289,7 @@ final class PublicSiteController
|
||||||
extract($vars, EXTR_SKIP);
|
extract($vars, EXTR_SKIP);
|
||||||
$settings = AppSettings::all();
|
$settings = AppSettings::all();
|
||||||
$publicSpecies = self::species();
|
$publicSpecies = self::species();
|
||||||
|
$publicPages = PublicPageService::all(true);
|
||||||
require dirname(__DIR__) . '/Views/public_site_layout.php';
|
require dirname(__DIR__) . '/Views/public_site_layout.php';
|
||||||
}
|
}
|
||||||
private static function notFound(): void
|
private static function notFound(): void
|
||||||
|
|
|
||||||
|
|
@ -759,6 +759,7 @@ final class SettingsController
|
||||||
'saved' => isset($_GET['saved']),
|
'saved' => isset($_GET['saved']),
|
||||||
'error' => (string) ($_GET['error'] ?? ''),
|
'error' => (string) ($_GET['error'] ?? ''),
|
||||||
'images' => $images,
|
'images' => $images,
|
||||||
|
'customPages' => PublicPageService::all(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -869,22 +870,30 @@ final class SettingsController
|
||||||
'public_site_routing' => $routing,
|
'public_site_routing' => $routing,
|
||||||
'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0',
|
'public_site_about_enabled' => isset($_POST['public_site_about_enabled']) ? '1' : '0',
|
||||||
];
|
];
|
||||||
foreach (
|
$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 {
|
||||||
'public_site_home_title' => 150,
|
if (!is_string($item)) return false;
|
||||||
'public_site_name' => 150,
|
return in_array($item, ['home','animals','about','donation','contact'], true)
|
||||||
'public_site_home_text' => 5000,
|
|| (bool) preg_match('/^custom:[a-z0-9-]{1,80}$/', $item);
|
||||||
'public_site_about_title' => 150,
|
}))) : [];
|
||||||
'public_site_about_text' => 10000,
|
$submittedPageSlugs = [];
|
||||||
'public_site_donation_title' => 150,
|
foreach ((array) ($_POST['pages'] ?? []) as $submittedPage) {
|
||||||
'public_site_donation_text' => 10000,
|
if (!is_array($submittedPage) || !empty($submittedPage['delete'])) continue;
|
||||||
'public_site_contact_text' => 5000,
|
$submittedSlug = PublicPageService::normalizeSlug((string) ($submittedPage['slug'] ?? $submittedPage['title'] ?? ''));
|
||||||
'public_site_legal_text' => 15000,
|
if ($submittedSlug !== '') $submittedPageSlugs[] = $submittedSlug;
|
||||||
'public_site_privacy_text' => 15000,
|
}
|
||||||
]
|
$navigation = array_values(array_filter($navigation, static function (string $item) use ($submittedPageSlugs): bool {
|
||||||
as $key => $max
|
return !str_starts_with($item, 'custom:') || in_array(substr($item, 7), $submittedPageSlugs, true);
|
||||||
) {
|
}));
|
||||||
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max);
|
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'] ?? ''));
|
$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)) {
|
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;
|
$values[$key] = $url;
|
||||||
}
|
}
|
||||||
AppSettings::save($values, Auth::id());
|
$db = DB::pdo();
|
||||||
AuditService::log('public_site_saved', '/settings/public-site/save', 'Configuration du site public modifiée');
|
try {
|
||||||
header('Location: /settings/public-site?saved=1');
|
$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();
|
exit();
|
||||||
}
|
}
|
||||||
public static function audit(): void
|
public static function audit(): void
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ final class AppSettings
|
||||||
'update_last_error' => '',
|
'update_last_error' => '',
|
||||||
'public_site_legal_text' => '',
|
'public_site_legal_text' => '',
|
||||||
'public_site_privacy_text' => '',
|
'public_site_privacy_text' => '',
|
||||||
|
'public_site_navigation_order' => '["home","animals","about","donation","contact"]',
|
||||||
];
|
];
|
||||||
private static ?array $cache = null;
|
private static ?array $cache = null;
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
|
|
|
||||||
81
app/Services/PublicPageService.php
Normal file
81
app/Services/PublicPageService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,10 +31,10 @@
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<section class="card h-100">
|
<section class="card h-100">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span class="badge bg-blue-lt mb-2">1.0.0-rc.33</span>
|
<span class="badge bg-blue-lt mb-2">1.0.0-rc.34</span>
|
||||||
<h3>Un site public mieux connecté</h3>
|
<h3>Un site public qui s’adapte au refuge</h3>
|
||||||
<p class="text-muted">Les réseaux sociaux renseignés par l’association rejoignent la navigation,
|
<p class="text-muted">Pages personnalisées, contenus longs, panneaux repliables et ordre du menu
|
||||||
avec des liens discrets, sécurisés et accessibles.</p>
|
réorganisable rendent chaque site aussi simple ou aussi complet que nécessaire.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ $requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_
|
||||||
$activePage = match($requestPath) {
|
$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 => ''
|
'/','/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'] ?? ''));
|
$activeSpecies = (string)($selectedSpecies ?? ($animal['species'] ?? ''));
|
||||||
$navClass = static fn (bool$active): string => $active ? ' class="active" aria-current="page"' : '';
|
$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'] ?? ''))]));
|
$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'],
|
'tiktok' => ['TikTok','♪'], 'whatsapp' => ['WhatsApp','☎'], 'linkedin' => ['LinkedIn','in'],
|
||||||
'x' => ['X','𝕏'], 'snapchat' => ['Snapchat','👻'],
|
'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')) {
|
if (!function_exists('public_age')) {
|
||||||
function public_age(array$a): string
|
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')) ?>"
|
href="<?= h($siteUrl()) ?>"><?php if (AssociationBrand::path()): ?><img src="<?= h($siteUrl('logo')) ?>"
|
||||||
alt=""><?php endif; ?><span><?= h($siteName) ?></span></a>
|
alt=""><?php endif; ?><span><?= h($siteName) ?></span></a>
|
||||||
<nav class="nav" aria-label="<?= h(t('public_site.navigation')) ?>">
|
<nav class="nav" aria-label="<?= h(t('public_site.navigation')) ?>">
|
||||||
<a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>">
|
<?php foreach ($navigationOrder as $navigationItem): ?>
|
||||||
<?= h(t('public_site.home')) ?></a><?php foreach ($publicSpecies as $species): ?>
|
<?php if ($navigationItem === 'home'): ?><a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>"><?= h(t('public_site.home')) ?></a>
|
||||||
<a<?= $navClass($activePage === 'animals' && $activeSpecies === $species['code']) ?>
|
<?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; ?>
|
||||||
href="<?= h($siteUrl('animals', ['espece' => $species['code']])) ?>">
|
<?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>
|
||||||
<?= h(($species['icon'] ?: '🐾') . ' ' . $species['name']) ?></a><?php endforeach; ?><?php if (($settings['public_site_about_enabled'] ?? '0') === '1'): ?>
|
<?php elseif ($navigationItem === 'donation'): ?><a<?= $navClass($activePage === 'donation') ?> href="<?= h($siteUrl('donation')) ?>"><?= h(t('public_site.donation')) ?></a>
|
||||||
<a<?= $navClass($activePage === 'about') ?> href="<?= h($siteUrl('about')) ?>">
|
<?php elseif ($navigationItem === 'contact'): ?><a<?= $navClass($activePage === 'contact') ?> href="<?= h($siteUrl('contact')) ?>"><?= h(t('public_site.contact')) ?></a>
|
||||||
<?= h($settings['public_site_about_title']) ?></a><?php endif; ?>
|
<?php elseif (str_starts_with((string)$navigationItem, 'custom:')):
|
||||||
<a<?= $navClass($activePage === 'donation') ?> href="<?= h($siteUrl('donation')) ?>">
|
$navigationSlug = substr((string)$navigationItem, 7);
|
||||||
<?= h(t('public_site.donation')) ?></a>
|
$navigationPage = $customPagesBySlug[$navigationSlug] ?? null;
|
||||||
<a<?= $navClass($activePage === 'contact') ?> href="<?= h($siteUrl('contact')) ?>">
|
if ($navigationPage): ?><a<?= $navClass($activeCustomSlug === $navigationSlug) ?> href="<?= h($siteUrl('custom', ['slug' => $navigationSlug])) ?>"><?= h($navigationPage['title']) ?></a><?php endif; endif; ?>
|
||||||
<?= h(t('public_site.contact')) ?></a><?php foreach ($socialNetworks as $network => [$label,$icon]):
|
<?php endforeach; ?>
|
||||||
|
<?php foreach ($socialNetworks as $network => [$label,$icon]):
|
||||||
$socialUrl = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
|
$socialUrl = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
|
||||||
if ($socialUrl === '') continue; ?>
|
if ($socialUrl === '') continue; ?>
|
||||||
<a class="nav-social-link nav-social-<?= h($network) ?> social-<?= h($network) ?>"
|
<a class="nav-social-link nav-social-<?= h($network) ?> social-<?= h($network) ?>"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="/settings/public-site/save"><input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
|
<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 mb-3">
|
||||||
<div class="card-status-top bg-green"></div>
|
<div class="card-status-top bg-green"></div>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
|
|
@ -78,10 +79,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card mb-3">
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||||
<div class="card-header">
|
<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>
|
<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="card-body row g-3">
|
||||||
<div class="col-12"><label class="form-label"><?= h(t('common.title')) ?></label><input class="form-control"
|
<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>
|
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"
|
class="form-control public-rich-text" name="public_site_home_text"
|
||||||
rows="4"><?= h($settings['public_site_home_text']) ?></textarea></div>
|
rows="4"><?= h($settings['public_site_home_text']) ?></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<div class="card mb-3">
|
<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>
|
||||||
<div class="card-header">
|
<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>
|
<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="card-body row g-3">
|
||||||
<div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox"
|
<div class="col-12"><label class="form-check form-switch"><input class="form-check-input" type="checkbox"
|
||||||
name="public_site_about_enabled" value="1"
|
name="public_site_about_enabled" value="1"
|
||||||
|
|
@ -106,11 +117,13 @@
|
||||||
class="form-control public-rich-text" name="public_site_about_text"
|
class="form-control public-rich-text" name="public_site_about_text"
|
||||||
rows="5"><?= h($settings['public_site_about_text']) ?></textarea></div>
|
rows="5"><?= h($settings['public_site_about_text']) ?></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<div class="card mb-3">
|
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="donation" draggable="true">
|
||||||
<div class="card-header">
|
<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>
|
<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="card-body row g-3">
|
||||||
<div class="col-md-4"><label class="form-label"><?= h(t('common.title')) ?></label><input
|
<div class="col-md-4"><label class="form-label"><?= h(t('common.title')) ?></label><input
|
||||||
class="form-control" name="public_site_donation_title"
|
class="form-control" name="public_site_donation_title"
|
||||||
|
|
@ -122,11 +135,13 @@
|
||||||
class="form-control public-rich-text" name="public_site_donation_text"
|
class="form-control public-rich-text" name="public_site_donation_text"
|
||||||
rows="4"><?= h($settings['public_site_donation_text']) ?></textarea></div>
|
rows="4"><?= h($settings['public_site_donation_text']) ?></textarea></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<div class="card mb-3">
|
<details class="card mb-2 public-page-panel public-sortable-page" data-page-key="contact" draggable="true">
|
||||||
<div class="card-header">
|
<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>
|
<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="card-body row g-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-info py-2"><?= h(t('public_site.contact_reuse')) ?></div><label
|
<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>
|
value="<?= h($settings['public_site_' . $network . '_url']) ?>" placeholder="https://..."></div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</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>
|
||||||
<div class="card mb-3">
|
<template id="public-page-template">
|
||||||
<div class="card-header">
|
<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>
|
<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="card-body row g-3">
|
||||||
<div class="col-md-6"><label class="form-label"><?= h(t('public_site.legal')) ?></label><textarea
|
<div class="col-md-6"><label class="form-label"><?= h(t('public_site.legal')) ?></label><textarea
|
||||||
class="form-control" name="public_site_legal_text"
|
class="form-control" name="public_site_legal_text"
|
||||||
|
|
@ -157,9 +202,12 @@
|
||||||
rows="16"><?= h(PublicSiteController::privacyText($settings)) ?></textarea></div>
|
rows="16"><?= h(PublicSiteController::privacyText($settings)) ?></textarea></div>
|
||||||
<div class="col-12 form-hint"><?= h(t('public_site.legal_hint')) ?></div>
|
<div class="col-12 form-hint"><?= h(t('public_site.legal_hint')) ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
<div class="text-end mb-4"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
|
<div class="text-end mb-4"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
|
||||||
</form>
|
</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>
|
<script>
|
||||||
let activePublicEditor = null;
|
let activePublicEditor = null;
|
||||||
const imageLibrary = document.getElementById('public-image-library');
|
const imageLibrary = document.getElementById('public-image-library');
|
||||||
|
|
@ -173,15 +221,20 @@ const insertImage = (button) => {
|
||||||
textarea.setRangeText(`\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
|
textarea.setRangeText(`\n`, textarea.selectionStart, textarea.selectionEnd, 'end');
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
};
|
};
|
||||||
document.querySelectorAll('textarea').forEach((textarea) => {
|
const enhanceTextarea = (textarea) => {
|
||||||
|
if (textarea.dataset.autoResize === '1') return;
|
||||||
|
textarea.dataset.autoResize = '1';
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
textarea.style.height = 'auto';
|
textarea.style.height = 'auto';
|
||||||
textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px';
|
textarea.style.height = Math.max(140, textarea.scrollHeight + 8) + 'px';
|
||||||
};
|
};
|
||||||
textarea.addEventListener('input', resize);
|
textarea.addEventListener('input', resize);
|
||||||
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; });
|
textarea.addEventListener('focus', () => { activePublicEditor = textarea; });
|
||||||
const toolbar = document.createElement('div');
|
const toolbar = document.createElement('div');
|
||||||
toolbar.className = 'btn-list mb-2';
|
toolbar.className = 'btn-list mb-2';
|
||||||
|
|
@ -211,7 +264,9 @@ document.querySelectorAll('.public-rich-text').forEach((textarea) => {
|
||||||
});
|
});
|
||||||
toolbar.append(imageButton);
|
toolbar.append(imageButton);
|
||||||
textarea.before(toolbar);
|
textarea.before(toolbar);
|
||||||
});
|
};
|
||||||
|
document.querySelectorAll('textarea').forEach(enhanceTextarea);
|
||||||
|
document.querySelectorAll('.public-rich-text').forEach(enhancePublicEditor);
|
||||||
document.querySelectorAll('.public-image-insert').forEach((button) => {
|
document.querySelectorAll('.public-image-insert').forEach((button) => {
|
||||||
button.addEventListener('click', () => insertImage(button));
|
button.addEventListener('click', () => insertImage(button));
|
||||||
});
|
});
|
||||||
|
|
@ -243,4 +298,86 @@ document.getElementById('public-image-upload').addEventListener('submit', async
|
||||||
submit.disabled = false;
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML
|
||||||
|
|
||||||
## Activer le site public
|
## 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 :
|
Deux modes existent :
|
||||||
|
|
||||||
|
|
@ -32,7 +32,9 @@ Au moins une photo doit être publique pour illustrer correctement la fiche. Les
|
||||||
|
|
||||||
## Pages publiques
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
13
migrations/099_public_custom_pages.sql
Normal file
13
migrations/099_public_custom_pages.sql
Normal 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);
|
||||||
|
|
@ -16,6 +16,7 @@ require_once __DIR__ . '/../app/Services/BackupService.php';
|
||||||
require_once __DIR__ . '/../app/Services/UpdateService.php';
|
require_once __DIR__ . '/../app/Services/UpdateService.php';
|
||||||
require_once __DIR__ . '/../app/Services/AppSettings.php';
|
require_once __DIR__ . '/../app/Services/AppSettings.php';
|
||||||
require_once __DIR__ . '/../app/Services/PublicContentFormatter.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/InstallationService.php';
|
||||||
require_once __DIR__ . '/../app/Services/SystemHealthService.php';
|
require_once __DIR__ . '/../app/Services/SystemHealthService.php';
|
||||||
require_once __DIR__ . '/../app/Services/NotificationService.php';
|
require_once __DIR__ . '/../app/Services/NotificationService.php';
|
||||||
|
|
|
||||||
|
|
@ -260,4 +260,20 @@ return [
|
||||||
'statistics.in_period'=>'during :period',
|
'statistics.in_period'=>'during :period',
|
||||||
'statistics.previous_period_comparison'=>'Comparison with the previous period',
|
'statistics.previous_period_comparison'=>'Comparison with the previous period',
|
||||||
'statistics.profiles_help_period'=>'Profiles cover animals admitted during :period, unless stated otherwise.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -260,4 +260,20 @@ return [
|
||||||
'statistics.in_period'=>'sur :period',
|
'statistics.in_period'=>'sur :period',
|
||||||
'statistics.previous_period_comparison'=>'Comparaison avec la période précédente',
|
'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.',
|
'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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ foreach (
|
||||||
'SpeciesService',
|
'SpeciesService',
|
||||||
'LabReferenceService',
|
'LabReferenceService',
|
||||||
'PublicContentFormatter',
|
'PublicContentFormatter',
|
||||||
|
'PublicPageService',
|
||||||
'ShelterRoomService',
|
'ShelterRoomService',
|
||||||
'GlobalSearchService',
|
'GlobalSearchService',
|
||||||
'PrivateMediaService',
|
'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($layout, 'rel="noopener noreferrer"'), 'Les liens externes ne sont pas protégés');
|
||||||
$assert(!str_contains($contact, '$socialNetworks = ['), 'Le catalogue social est encore dupliqué');
|
$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 {
|
$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é');
|
$assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé');
|
||||||
$hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT);
|
$hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue