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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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) ?>"
|
||||
|
|
|
|||
|
|
@ -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(`\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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue