Préparer la version 1.0.0-rc.4

This commit is contained in:
Alexandre NOEL 2026-09-07 14:15:45 +02:00
commit 6e3beea211
38 changed files with 388 additions and 56 deletions

View file

@ -13,6 +13,8 @@ trait AnimalBrowseActions
$quality = trim((string) ($_GET['quality'] ?? ''));
$availability = trim((string) ($_GET['availability'] ?? ''));
$species = SpeciesService::normalize((string) ($_GET['species'] ?? ''));
$attribute = (string) ($_GET['attribute'] ?? '');
$attributeValue = trim((string) ($_GET['value'] ?? ''));
$sort = (string) ($_GET['sort'] ?? 'updated_at');
$dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC';
$requestedView = (string) ($_GET['view'] ?? '');
@ -59,6 +61,20 @@ trait AnimalBrowseActions
} else {
$species = '';
}
$attributeColumns = [
'color' => 'a.color',
'breed' => 'a.breed',
'hair_type' => 'a.hair_type',
'sex' => 'a.sex',
'status' => 'a.status',
];
if (isset($attributeColumns[$attribute]) && $attributeValue !== '') {
$where[] = 'lower(trim(' . $attributeColumns[$attribute] . '))=lower(trim(:attribute_value))';
$params[':attribute_value'] = $attributeValue;
} else {
$attribute = '';
$attributeValue = '';
}
if ($q !== '') {
$where[] = '(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :q)';
@ -407,6 +423,8 @@ trait AnimalBrowseActions
'availability' => $availability,
'viewMode' => $viewMode,
'species' => $species,
'attribute' => $attribute,
'attributeValue' => $attributeValue,
'speciesOptions' => SpeciesService::all(false),
]);
}

View file

@ -486,7 +486,7 @@ final class AnimalsController
try {
$svc->refreshByChip($id, $chipId);
} catch (Throwable $e) {
// Le cache conserve l'état d'erreur fourni par le service.
error_log('I-CAD refresh failed for animal ' . $id . ': ' . $e->getMessage());
}
$cached = $svc->getCachedByAnimal($id);
$stale = $svc->isStale($cached, 86400);

View file

@ -592,8 +592,26 @@ final class SettingsController
'groups' => $groups,
'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''),
'speciesOptions' => SpeciesService::all(true),
]);
}
public static function addLabReferenceSpecies(): void
{
self::admin();
self::postOnly();
try {
$count = LabReferenceService::copySpecies(
(string) ($_POST['source_species'] ?? ''),
(string) ($_POST['target_species'] ?? ''),
Auth::id(),
);
AuditService::log('lab_reference_species_added', '/settings/lab-references/species', "$count références biologiques ajoutées");
header('Location: /settings/lab-references?saved=1');
} catch (Throwable $e) {
header('Location: /settings/lab-references?error=' . rawurlencode(SecurityService::publicError($e)));
}
exit();
}
public static function saveLabReferences(): void
{
self::admin();
@ -716,7 +734,7 @@ final class SettingsController
self::admin();
self::postOnly();
$theme = (string) ($_POST['public_site_theme'] ?? 'warm');
if (!in_array($theme, ['warm', 'nature', 'minimal'], true)) {
if (!in_array($theme, ['warm', 'nature', 'minimal', 'custom'], true)) {
$theme = 'warm';
}
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
@ -732,6 +750,7 @@ final class SettingsController
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,
@ -745,7 +764,13 @@ final class SettingsController
) {
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max);
}
foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url'] as $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)) {
header('Location: /settings/public-site?error=' . rawurlencode(t('public_site.invalid_css')));
exit();
}
$values['public_site_custom_css'] = $customCss;
foreach (['public_site_donation_url', 'public_site_facebook_url', 'public_site_instagram_url', 'public_site_mastodon_url', 'public_site_youtube_url', 'public_site_tiktok_url', 'public_site_bluesky_url'] as $key) {
$url = trim((string) ($_POST[$key] ?? ''));
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) {

View file

@ -341,6 +341,9 @@ switch ($path) {
case '/settings/lab-references/save':
SettingsController::saveLabReferences();
break;
case '/settings/lab-references/species':
SettingsController::addLabReferenceSpecies();
break;
case '/settings/public-site':
SettingsController::publicSite();
break;

View file

@ -22,6 +22,8 @@ final class AppSettings
'app_language' => 'fr',
'public_site_enabled' => '0',
'public_site_theme' => 'warm',
'public_site_name' => '',
'public_site_custom_css' => '',
'public_site_routing' => 'integrated',
'public_site_home_title' => 'Ils attendent une famille',
'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.',
@ -35,6 +37,10 @@ final class AppSettings
'public_site_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.',
'public_site_facebook_url' => '',
'public_site_instagram_url' => '',
'public_site_mastodon_url' => '',
'public_site_youtube_url' => '',
'public_site_tiktok_url' => '',
'public_site_bluesky_url' => '',
'notification_email_enabled' => '0',
'notification_email_frequency' => 'weekly',
'notification_email_recipient' => '',

View file

@ -87,11 +87,12 @@ final class IcadService
throw new RuntimeException(t('service.icad.credentials_missing'));
}
$cookie = sys_get_temp_dir() . '/twix_icad_' . bin2hex(random_bytes(6)) . '.cookie';
$cookie = sys_get_temp_dir() . '/globinours_icad_' . bin2hex(random_bytes(6)) . '.cookie';
try {
// 1) GET HOME => token _token du formulaire login ACTEUR
$r1 = $this->req('https://www.i-cad.fr/', $cookie);
$this->assertSuccess($r1, t('service.icad.home_unavailable'));
$token = $this->extractToken($r1['body'], '_token'); // prend le 1er _token trouvé
@ -109,16 +110,19 @@ final class IcadService
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/',
]);
$this->assertSuccess($r2, t('service.icad.login_failed'));
// 3) Vérif session
$r2b = $this->req('https://www.i-cad.fr/pro', $cookie);
if (stripos($r2b['body'], 'Se connecter') !== false) {
$this->assertSuccess($r2b, t('service.icad.login_failed'));
if (stripos($r2b['body'], 'Se connecter') !== false || str_contains((string)$r2b['effective_url'], '/login')) {
throw new RuntimeException(t('service.icad.login_failed'));
}
// 4) GET recherche. I-CAD a renommé le formulaire
// search_animal en animal_search en 2026 : on accepte les deux.
$r3 = $this->req('https://www.i-cad.fr/identification/recherche', $cookie);
$this->assertSuccess($r3, t('service.icad.search_unavailable'));
[$searchForm, $searchToken] = $this->extractAnimalSearchForm($r3['body']);
// 5) POST recherche
@ -136,6 +140,7 @@ final class IcadService
'Origin: https://www.i-cad.fr',
'Referer: https://www.i-cad.fr/identification/recherche',
]);
$this->assertSuccess($r4, t('service.icad.search_unavailable'));
$animalPath = $this->extractAnimalPath($r4);
if (!$animalPath) {
@ -144,6 +149,7 @@ final class IcadService
// 6) GET fiche animal
$r5 = $this->req('https://www.i-cad.fr' . $animalPath, $cookie);
$this->assertSuccess($r5, t('service.icad.record_unavailable'));
$data = $this->parseAnimalHtml($r5['body'], $animalPath);
$this->saveCache($animalId, $chipId, 'ok', null, $data);
@ -158,6 +164,14 @@ final class IcadService
// ========= HTTP =========
private function assertSuccess(array $response, string $message): void
{
$status = (int) ($response['status'] ?? 0);
if ($status < 200 || $status >= 400) {
throw new RuntimeException($message . ' (HTTP ' . $status . ')');
}
}
private function req(
string $url,
string $cookieFile,

View file

@ -33,4 +33,23 @@ final class LabReferenceService
}
return $out;
}
public static function copySpecies(string $source, string $target, ?int $userId): int
{
$source = SpeciesService::normalize($source);
$target = SpeciesService::normalize($target);
if ($source === $target || !SpeciesService::exists($source, false) || !SpeciesService::exists($target, false)) {
throw new RuntimeException(t('lab_reference.invalid_species'));
}
$stmt = DB::pdo()->prepare(
"INSERT OR IGNORE INTO lab_reference_ranges(analyzer_code,analyzer_name,species_code,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,active,sort_order,updated_by,updated_at)
SELECT analyzer_code,analyzer_name,:target,parameter_code,parameter_name,unit,reference_min,reference_max,source_note,active,sort_order,:user,datetime('now')
FROM lab_reference_ranges WHERE species_code=:source",
);
$stmt->execute([':source' => $source, ':target' => $target, ':user' => $userId]);
if ($stmt->rowCount() === 0) {
throw new RuntimeException(t('lab_reference.nothing_copied'));
}
return $stmt->rowCount();
}
}

View file

@ -21,12 +21,13 @@ final class PrivateMediaService
return dirname(__DIR__, 2) . '/public/media/animals/' . $animalId;
}
public static function ensure(string $dir): void
public static function ensure(string $dir, bool $public = false): void
{
if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) {
$mode = $public ? 0755 : 0700;
if (!is_dir($dir) && !mkdir($dir, $mode, true) && !is_dir($dir)) {
throw new RuntimeException(t('service.image.directory_failed'));
}
@chmod($dir, 0700);
@chmod($dir, $mode);
}
public static function moveVisibility(int $animalId, string $filename, bool $toPublic): void
@ -45,7 +46,7 @@ final class PrivateMediaService
}
throw new RuntimeException(t('service.private_media.not_found'));
}
self::ensure($to);
self::ensure($to, $toPublic);
if (!rename($source, $target)) {
throw new RuntimeException(t('service.private_media.move_failed'));
}
@ -55,6 +56,19 @@ final class PrivateMediaService
public static function migrateLegacy(): void
{
$db = DB::pdo();
$public = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=1')
->fetchAll(PDO::FETCH_ASSOC);
foreach ($public as $row) {
$dir = self::publicDir((int) $row['animal_id']);
if (is_dir($dir)) {
@chmod($dir, 0755);
}
$file = $dir . '/' . basename((string) $row['filename']);
if (is_file($file)) {
@chmod($file, 0644);
}
}
$private = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0')
->fetchAll(PDO::FETCH_ASSOC);

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
final class PublicContentFormatter
{
public static function render(string $text): string
{
$lines = preg_split('/\R/u', trim($text)) ?: [];
$html = [];
$listOpen = false;
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
if ($listOpen) {
$html[] = '</ul>';
$listOpen = false;
}
continue;
}
if (preg_match('/^[-*]\s+(.+)$/u', $line, $match)) {
if (!$listOpen) {
$html[] = '<ul>';
$listOpen = true;
}
$html[] = '<li>' . self::inline($match[1]) . '</li>';
continue;
}
if ($listOpen) {
$html[] = '</ul>';
$listOpen = false;
}
if (preg_match('/^(#{1,3})\s+(.+)$/u', $line, $match)) {
$level = strlen($match[1]) + 1;
$html[] = '<h' . $level . '>' . self::inline($match[2]) . '</h' . $level . '>';
continue;
}
$html[] = '<p>' . self::inline($line) . '</p>';
}
if ($listOpen) {
$html[] = '</ul>';
}
return implode("\n", $html);
}
private static function inline(string $text): string
{
$links = [];
$text = preg_replace_callback('/\[([^\]]+)\]\((https:\/\/[^\s)]+)\)/u', static function (array $match) use (&$links): string {
if (!filter_var($match[2], FILTER_VALIDATE_URL)) {
return $match[0];
}
$token = "\x1A" . count($links) . "\x1A";
$links[] = '<a href="' . htmlspecialchars($match[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') .
'" rel="noopener noreferrer">' . htmlspecialchars($match[1], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</a>';
return $token;
}, $text) ?? $text;
$text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$text = preg_replace('/\*\*(.+?)\*\*/u', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/(?<!\*)\*([^*]+)\*(?!\*)/u', '<em>$1</em>', $text) ?? $text;
foreach ($links as $index => $link) {
$text = str_replace("\x1A" . $index . "\x1A", $link, $text);
}
return $text;
}
}

View file

@ -607,9 +607,14 @@ final class StatisticsService
"SELECT COALESCE(NULLIF(TRIM(a.breed),''),'Non renseignée') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$hairTypes = $rows(
$entered .
"SELECT COALESCE(NULLIF(TRIM(a.hair_type),''),'Non renseigné') label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC,label",
$p,
);
$species = $rows(
$entered .
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'Chats' WHEN 'chat' THEN 'Chats' WHEN 'dog' THEN 'Chiens' WHEN 'chien' THEN 'Chiens' ELSE COALESCE(NULLIF(TRIM(a.species),''),'Inconnue') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
"SELECT CASE lower(a.species) WHEN 'cat' THEN 'chat' WHEN 'dog' THEN 'chien' ELSE COALESCE(NULLIF(TRIM(lower(a.species)),''),'unknown') END label,COUNT(*) total FROM entered e JOIN animals a ON a.id=e.animal_id GROUP BY label ORDER BY total DESC",
$p,
);
$intakeTypes = $rows(
@ -680,6 +685,7 @@ final class StatisticsService
return [
'colors' => $colors,
'breeds' => $breeds,
'hairTypes' => $hairTypes,
'species' => $species,
'intakeTypes' => $intakeTypes,
'intakeReasons' => $intakeReasons,

View file

@ -94,6 +94,22 @@ switch ($animal['sex']) {
text-decoration: none;
}
.animal-tabs {
overflow: visible;
position: relative;
z-index: 20;
}
.animal-tabs .dropdown-menu {
z-index: 2050;
}
@media (max-width: 767.98px) {
.animal-tabs {
flex-wrap: wrap;
}
}
</style>
@ -429,7 +445,7 @@ $registrationClass = $registration === 'registered' ? 'bg-green-lt' : ($registra
<div class="mt-">
<ul id="submenus" class="nav nav-tabs nav-tabs-alt mt-3 mb-0">
<ul id="submenus" class="nav nav-tabs nav-tabs-alt animal-tabs mt-3 mb-0">
<li class="nav-item"><a href="#tab-ident" class="nav-link active" data-bs-toggle="tab">🧬
<?= h(t('animal.tab.identity')) ?></a></li>
<li class="nav-item"><a href="#tab-activity" class="nav-link" data-bs-toggle="tab">📊

View file

@ -9,6 +9,8 @@ $quality = $quality ?? '';
$qualityLabel = $qualityLabel ?? '';
$species = $species ?? '';
$speciesOptions = $speciesOptions ?? [];
$attribute = $attribute ?? '';
$attributeValue = $attributeValue ?? '';
$viewMode = $viewMode ?? 'table';
$speciesByCode = [];
foreach ($speciesOptions as $speciesOption) {
@ -34,12 +36,21 @@ $mosaicQs['view'] = 'mosaic';
?>
<?php if ($attribute !== ''): ?>
<div class="alert alert-info d-flex align-items-center gap-2">
<span><?= h(t('animals.filtered_by', ['value' => $attributeValue])) ?></span>
<a class="btn btn-sm btn-outline-primary ms-auto" href="/animals"><?= h(t('common.clear')) ?></a>
</div>
<?php endif; ?>
<div class="card">
<div class="card-body">
<form class="row g-2 align-items-center" method="get" action="/animals">
<?php if ($quality !== ''): ?><input type="hidden" name="quality" value="<?= h($quality) ?>"><?php endif; ?>
<?php if ($attribute !== ''): ?><input type="hidden" name="attribute" value="<?= h($attribute) ?>"><input
type="hidden" name="value" value="<?= h($attributeValue) ?>"><?php endif; ?>
<input type="hidden" name="sort" value="<?= h($sort) ?>">
<input type="hidden" name="dir" value="<?= h(strtolower($dir)) ?>">
<div class="col-12">

View file

@ -6,6 +6,7 @@ foreach ($rooms as $room) {
}
?>
<form method="post" action="/care-round/setup/save" class="card">
<input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h3 class="card-title mb-1"><?= h(t('care.configure_boxes')) ?></h3>

View file

@ -31,10 +31,10 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.3</span>
<h3>Stabilisation avant la 1.0</h3>
<p class="text-muted">Découpage des fichiers volumineux, recherche universelle, correction de
l’installation vierge, jeu de démonstration enrichi et harmonisation de la présentation.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.4</span>
<h3>Retours de terrain avant la 1.0</h3>
<p class="text-muted">Photos publiques et déplacements fiabilisés, statistiques navigables,
références biologiques extensibles et site public personnalisable.</p>
</div>
</section>
</div>

View file

@ -5,7 +5,7 @@ $obfuscate = static fn (string$value): string => implode('', array_reverse(mb_st
<div class="wrap">
<div class="content-card">
<h1 class="page-title"><?= h(t('public_site.contact')) ?></h1>
<p><?= nl2br(h($settings['public_site_contact_text'])) ?></p>
<div><?= PublicContentFormatter::render($settings['public_site_contact_text']) ?></div>
<div class="contact-list"><?php if ($fullAddress !== ''): ?><div class="contact-item">📍
<?= h($fullAddress) ?></div><?php endif; ?><?php if ($settings['association_phone']): ?><div
class="contact-item">☎ <span
@ -13,11 +13,13 @@ $obfuscate = static fn (string$value): string => implode('', array_reverse(mb_st
<?php endif; ?><?php if ($settings['association_email']): ?><div class="contact-item">✉ <span
class="contact-obfuscated"><?= h($obfuscate($settings['association_email'])) ?></span></div>
<?php endif; ?></div>
<div class="chips" style="margin-top:25px"><?php if ($settings['public_site_facebook_url']): ?><a
class="button secondary" href="<?= h($settings['public_site_facebook_url']) ?>"
rel="noopener noreferrer">Facebook</a><?php endif; ?><?php if ($settings['public_site_instagram_url']): ?><a
class="button secondary" href="<?= h($settings['public_site_instagram_url']) ?>"
rel="noopener noreferrer">Instagram</a><?php endif; ?></div>
<div class="chips" style="margin-top:25px">
<?php foreach (['facebook' => 'Facebook','instagram' => 'Instagram','mastodon' => 'Mastodon','youtube' => 'YouTube','tiktok' => 'TikTok','bluesky' => 'Bluesky'] as $network => $label):
$url = trim((string)($settings['public_site_' . $network . '_url'] ?? ''));
if ($url === '') continue; ?>
<a class="button secondary" href="<?= h($url) ?>" rel="noopener noreferrer"><?= h($label) ?></a>
<?php endforeach; ?>
</div>
<?php if ($lat !== null && $lng !== null): ?><h2 style="margin-top:35px">
<?= h(t('public_site.map_title')) ?></h2>
<div id="association-map" class="contact-map" aria-label="<?= h(t('public_site.map_title')) ?>"></div>

View file

@ -1,7 +1,7 @@
<section class="hero">
<div class="wrap">
<h1><?= h($settings['public_site_home_title']) ?></h1>
<p><?= nl2br(h($settings['public_site_home_text'])) ?></p><a class="button"
<div><?= PublicContentFormatter::render($settings['public_site_home_text']) ?></div><a class="button"
href="<?= h($siteUrl('animals')) ?>"><?= h(t('public_site.discover')) ?></a>
</div>
</section>

View file

@ -1,8 +1,9 @@
<?php
require_once __DIR__ . '/_helpers.php';
$siteUrl = static fn (string$page = 'home', array$query = []): string => PublicSiteController::url($page, $query);
$theme = in_array($settings['public_site_theme'] ?? 'warm', ['warm','nature','minimal'], true) ? $settings['public_site_theme'] : 'warm';
$theme = in_array($settings['public_site_theme'] ?? 'warm', ['warm','nature','minimal','custom'], true) ? $settings['public_site_theme'] : 'warm';
$association = $settings['association_name'] ?: 'Association';
$siteName = trim((string)($settings['public_site_name'] ?? '')) ?: $association;
$viewFile = __DIR__ . '/' . $view;
$requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'), '/') ?: '/';
$activePage = match($requestPath) {
@ -38,13 +39,19 @@ if (!function_exists('public_age')) {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= h($title . ' — ' . $association) ?></title>
<title><?= h($title . ' — ' . $siteName) ?></title>
<meta name="description"
content="<?= h(mb_substr(strip_tags((string)($settings['public_site_home_text'] ?? '')), 0, 155)) ?>">
<meta name="robots"
content="<?= ($settings['public_site_enabled'] ?? '0') === '1' ? 'index,follow' : 'noindex,nofollow' ?>">
<?php if ($hasContactMap): ?>
<link rel="stylesheet" href="/assets/vendor/leaflet/leaflet.css"><?php endif; ?><style>
@font-face {
font-family: "Globinours Emoji";
src: url("/assets/fonts/globinours-emoji.woff2") format("woff2");
font-display: swap
}
:root {
--accent: #d87945;
--accent-dark: #a84f29;
@ -81,6 +88,20 @@ if (!function_exists('public_age')) {
--shadow: none
}
[data-theme=custom] {
--accent: #d87945;
--accent-dark: #a84f29;
--ink: #2d2926;
--muted: #756d67;
--paper: #fffaf5;
--surface: #fff;
--line: #eadfd5
}
<?php if ($theme === 'custom' && trim((string)($settings['public_site_custom_css'] ?? '')) !== ''): ?>
<?= $settings['public_site_custom_css'] ?>
<?php endif; ?>
* {
box-sizing: border-box
}
@ -89,7 +110,7 @@ if (!function_exists('public_age')) {
margin: 0;
background: var(--paper);
color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, Segoe UI, sans-serif
font: 16px/1.6 "Globinours Emoji", system-ui, -apple-system, Segoe UI, sans-serif
}
a {
@ -432,7 +453,7 @@ if (!function_exists('public_age')) {
<header class="site-head">
<div class="wrap head-row"><a class="brand"
href="<?= h($siteUrl()) ?>"><?php if (AssociationBrand::path()): ?><img src="<?= h($siteUrl('logo')) ?>"
alt=""><?php endif; ?><span><?= h($association) ?></span></a>
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): ?>
@ -460,7 +481,7 @@ if (!function_exists('public_age')) {
: <?= h($settings['association_siret']) ?></span><?php endif; ?></div>
</div>
<div class="footer-powered"><?= h(t('public_site.powered_by')) ?> <a
href="https://nnsprod.com/git/oversu/Globinours" target="_blank"
href="https://globinours.fr" target="_blank"
rel="noopener noreferrer">Globinours</a></div>
</div>
</footer>

View file

@ -2,7 +2,7 @@
<div class="wrap">
<article class="content-card">
<h1 class="page-title"><?= h($title) ?></h1>
<div><?= nl2br(h($content)) ?></div>
<div><?= PublicContentFormatter::render($content) ?></div>
<?php if ($kind === 'donation' && !empty($settings['public_site_donation_url'])): ?><p
style="margin-top:28px"><a class="button" href="<?= h($settings['public_site_donation_url']) ?>"
rel="noopener noreferrer"><?= h(t('public_site.make_donation')) ?></a></p><?php endif; ?>

View file

@ -7,6 +7,26 @@
<?php endif; ?><?php if ($error !== ''): ?><div class="alert alert-danger">⚠️ <?= h($error) ?></div><?php endif; ?>
<div class="alert alert-info"><strong><?= h(t('lab_reference.priority_title')) ?></strong>
<?= h(t('lab_reference.priority_help')) ?></div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title"><?= h(t('lab_reference.add_species')) ?></h3></div>
<div class="card-body">
<form class="row g-2 align-items-end" method="post" action="/settings/lab-references/species">
<input type="hidden" name="csrf" value="<?= h(Auth::csrf()) ?>">
<div class="col-md-5"><label class="form-label"><?= h(t('lab_reference.copy_from')) ?></label><select
class="form-select" name="source_species" required>
<?php foreach (array_unique(array_merge(...array_map('array_keys', array_values($groups)))) as $code): ?>
<option value="<?= h($code) ?>"><?= h(SpeciesService::label($code)) ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-5"><label class="form-label"><?= h(t('lab_reference.target_species')) ?></label><select
class="form-select" name="target_species" required>
<?php foreach ($speciesOptions as $species): ?><option value="<?= h($species['code']) ?>">
<?= h($species['icon'] . ' ' . $species['name']) ?></option><?php endforeach; ?>
</select></div>
<div class="col-md-2"><button class="btn btn-primary w-100"><?= h(t('common.add')) ?></button></div>
</form>
<div class="form-hint mt-2"><?= h(t('lab_reference.copy_help')) ?></div>
</div>
</div>
<form method="post" action="/settings/lab-references/save"><input type="hidden" name="csrf"
value="<?= h(Auth::csrf()) ?>">
<?php foreach ($groups as $analyzer => $speciesGroups): ?><div class="card mb-3">

View file

@ -42,13 +42,20 @@
</div>
<div class="col-12"><label class="form-label"><?= h(t('public_site.theme')) ?></label>
<div class="row g-2">
<?php foreach (['warm' => ['🧡',t('public_site.theme_warm')],'nature' => ['🌿',t('public_site.theme_nature')],'minimal' => ['◻️',t('public_site.theme_minimal')]] as $key => $theme): ?>
<div class="col-md-4"><label class="form-selectgroup-item w-100"><input type="radio"
<?php foreach (['warm' => ['🧡',t('public_site.theme_warm')],'nature' => ['🌿',t('public_site.theme_nature')],'minimal' => ['◻️',t('public_site.theme_minimal')],'custom' => ['🎨',t('public_site.theme_custom')]] as $key => $theme): ?>
<div class="col-md-3"><label class="form-selectgroup-item w-100"><input type="radio"
name="public_site_theme" value="<?= h($key) ?>" class="form-selectgroup-input"
<?= ($settings['public_site_theme'] ?? 'warm') === $key ? 'checked' : '' ?>><span
class="form-selectgroup-label d-flex align-items-center gap-2"><?= $theme[0] ?>
<strong><?= h($theme[1]) ?></strong></span></label></div><?php endforeach; ?></div>
</div>
<div class="col-md-6"><label class="form-label"><?= h(t('public_site.site_name')) ?></label><input
class="form-control" name="public_site_name" value="<?= h($settings['public_site_name']) ?>"
placeholder="<?= h($settings['association_name']) ?>"></div>
<div class="col-12"><label class="form-label"><?= h(t('public_site.custom_css')) ?></label><textarea
class="form-control font-monospace" name="public_site_custom_css" rows="8"
placeholder=":root { --accent: #d87945; }"><?= h($settings['public_site_custom_css']) ?></textarea>
<div class="form-hint"><?= h(t('public_site.custom_css_help')) ?></div></div>
</div>
</div>
</div>
@ -60,7 +67,7 @@
<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>
<div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea
class="form-control" 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>
</div>
</div>
@ -77,7 +84,7 @@
class="form-control" name="public_site_about_title"
value="<?= h($settings['public_site_about_title']) ?>"></div>
<div class="col-md-8"><label class="form-label"><?= h(t('common.text')) ?></label><textarea
class="form-control" 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>
</div>
</div>
@ -93,7 +100,7 @@
class="form-control" type="url" name="public_site_donation_url"
value="<?= h($settings['public_site_donation_url']) ?>" placeholder="https://..."></div>
<div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea
class="form-control" 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>
</div>
</div>
@ -104,13 +111,18 @@
<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
class="form-label"><?= h(t('common.text')) ?></label><textarea class="form-control"
class="form-label"><?= h(t('common.text')) ?></label><textarea class="form-control public-rich-text"
name="public_site_contact_text" rows="3"><?= h($settings['public_site_contact_text']) ?></textarea>
</div>
<div class="col-md-6"><label class="form-label">Facebook</label><input class="form-control" type="url"
name="public_site_facebook_url" value="<?= h($settings['public_site_facebook_url']) ?>"></div>
<div class="col-md-6"><label class="form-label">Instagram</label><input class="form-control" type="url"
name="public_site_instagram_url" value="<?= h($settings['public_site_instagram_url']) ?>"></div>
<?php foreach (['mastodon' => 'Mastodon','youtube' => 'YouTube','tiktok' => 'TikTok','bluesky' => 'Bluesky'] as $network => $label): ?>
<div class="col-md-6"><label class="form-label"><?= h($label) ?></label><input class="form-control"
type="url" name="public_site_<?= h($network) ?>_url"
value="<?= h($settings['public_site_' . $network . '_url']) ?>" placeholder="https://..."></div>
<?php endforeach; ?>
</div>
</div>
<div class="card mb-3">
@ -129,3 +141,25 @@
</div>
<div class="text-end mb-4"><button class="btn btn-primary"><?= h(t('common.save')) ?></button></div>
</form>
<script>
document.querySelectorAll('.public-rich-text').forEach((textarea) => {
const toolbar = document.createElement('div');
toolbar.className = 'btn-list mb-2';
const actions = [['B', '**', '**'], ['I', '*', '*'], ['Titre', '## ', ''], ['Liste', '- ', ''], ['Lien', '[', '](https://)']];
actions.forEach(([label, before, after]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-outline-secondary';
button.textContent = label;
button.addEventListener('click', () => {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = textarea.value.slice(start, end);
textarea.setRangeText(before + selected + after, start, end, 'select');
textarea.focus();
});
toolbar.append(button);
});
textarea.before(toolbar);
});
</script>

View file

@ -10,13 +10,18 @@ foreach (['unknown','stray_found','unwanted_litter','owner_health','owner_death'
$intakeReasonLabels[$key] = t('statistics.reason.' . $key);
}
$distributionLabels = ['Femelles' => t('statistics.value.females'),'Mâles' => t('statistics.value.males'),'Inconnu' => t('statistics.value.unknown'),'Non renseigné' => t('statistics.value.not_provided'),'Non renseignée' => t('statistics.value.not_provided_f'),'Moins d’un an' => t('statistics.value.under_year'),'1 à 2 ans' => t('statistics.value.one_two_years'),'3 à 7 ans' => t('statistics.value.three_seven_years'),'8 ans et plus' => t('statistics.value.eight_plus'),'Moins d’un mois' => t('statistics.value.under_month'),'1 à 3 mois' => t('statistics.value.one_three_months'),'3 à 6 mois' => t('statistics.value.three_six_months'),'6 à 12 mois' => t('statistics.value.six_twelve_months'),'Plus d’un an' => t('statistics.value.over_year'),'Chats' => t('statistics.value.cats'),'Chiens' => t('statistics.value.dogs'),'Inconnue' => t('statistics.value.unknown_f'),'Refuge' => t('status.refuge'),'Famille d’accueil' => t('status.fa'),'Accueil permanent' => t('statistics.value.permanent_foster'),'Chats libres suivis' => t('statistics.free_cats'),'Quarantaine' => t('status.quarantaine'),'Infirmerie' => t('status.soin'),'Réservé' => t('status.reserve'),'Site non renseigné' => t('statistics.value.unprovided_site')];
$speciesLabels = ['unknown' => t('statistics.value.unknown_f')];
foreach (SpeciesService::all(false) as $speciesOption) {
$speciesLabels[(string)$speciesOption['code']] = (string)$speciesOption['name'];
}
$groups = [
[t('statistics.intake_modes'),$distributions['intakeTypes'],$intakeTypeLabels,'green'],
[t('statistics.intake_reasons'),$distributions['intakeReasons'],$intakeReasonLabels,'orange'],
[t('statistics.colors'),$distributions['colors'],[],'blue'],
[t('statistics.breeds'),$distributions['breeds'],[],'purple'],
[t('statistics.species'),$distributions['species'],[],'cyan'],
[t('statistics.sex'),$distributions['sex'],[],'pink'],
[t('statistics.colors'),$distributions['colors'],[],'blue','color'],
[t('statistics.breeds'),$distributions['breeds'],[],'purple','breed'],
[t('statistics.hair_types'),$distributions['hairTypes'] ?? [],[],'azure','hair_type'],
[t('statistics.species'),$distributions['species'],$speciesLabels,'cyan','species'],
[t('statistics.sex'),$distributions['sex'],[],'pink','sex'],
[t('statistics.age_at_entry'),$distributions['ages'],[],'orange'],
[t('statistics.origins'),$distributions['origins'],[],'green','municipality'],
[t('statistics.adoption_delay'),$distributions['stays'],[],'azure'],
@ -312,11 +317,18 @@ if (empty($costs['by_clinic'])):?><span class="text-muted"><?= h(t('statistics.n
<?php foreach ($items as $label => $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label;
$width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0;
$pct = $sum > 0 ? round($total / $sum * 100) : 0;
$cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $label]); ?><div>
$cityUrl = '/statistics?' . http_build_query(['year' => $allYears ? 'all' : $year,'municipality' => $label]);
$filterValue = $linkType === 'sex' ? match ((string)$label) {
'Femelles' => 'F', 'Mâles' => 'M', default => ''
} : (string)$label;
$animalUrl = $linkType === 'species'
? '/animals?' . http_build_query(['status' => '', 'species' => SpeciesService::normalize((string)$label)])
: '/animals?' . http_build_query(['status' => '', 'attribute' => $linkType, 'value' => $filterValue]); ?><div>
<div class="d-flex justify-content-between gap-3 mb-1">
<?php if ($linkType === 'municipality' && $municipality === ''): ?><a
class="text-truncate fw-semibold" href="<?= h($cityUrl) ?>"
title="<?= h(t('statistics.show_city', ['city' => $display])) ?>"><?= h($display) ?></a><?php else: ?><span
title="<?= h(t('statistics.show_city', ['city' => $display])) ?>"><?= h($display) ?></a><?php elseif (in_array($linkType, ['color','breed','hair_type','species','sex'], true) && !str_starts_with((string)$label, 'Non renseign') && $filterValue !== ''): ?><a
class="text-truncate fw-semibold" href="<?= h($animalUrl) ?>"><?= h($display) ?></a><?php else: ?><span
class="text-truncate"
title="<?= h($display) ?>"><?= h($display) ?></span><?php endif; ?><strong
class="text-nowrap"><?= (int)$total ?> <span