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

@ -2,6 +2,18 @@
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.4] — 2026-09-07
### Correctifs et site public
- rétablissement des déplacements depuis la configuration des tournées et réparation des droits des photos publiques ;
- correction de l’affichage du menu « Plus » dans les fiches animales ;
- filtres cliquables depuis les statistiques pour les espèces, sexes, couleurs, races et types de poil ;
- ajout d’espèces aux références biologiques par copie d’un référentiel existant ;
- évolution du site public : nom distinct, thème CSS personnalisé, mise en forme guidée et réseaux sociaux supplémentaires ;
- lien officiel Globinours dans le pied de page et police emoji locale homogène sur Windows et Linux ;
- diagnostics I-CAD plus précis en cas d’indisponibilité ou de changement du service distant.
## [1.0.0-rc.3] — 2026-09-03 ## [1.0.0-rc.3] — 2026-09-03
### Stabilisation ### Stabilisation
@ -43,7 +55,7 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s
- annuaire, tournées, statistiques, registre administratif et exports ; - annuaire, tournées, statistiques, registre administratif et exports ;
- comptes, rôles, permissions, audit et sauvegardes ; - comptes, rôles, permissions, audit et sauvegardes ;
- import ASM3 en simulation puis transaction protégée ; - import ASM3 en simulation puis transaction protégée ;
- interface responsive, PWA, mini-site public facultatif et internationalisation FR/EN. - interface responsive, PWA, site public facultatif et internationalisation FR/EN.
[1.0.0]: https://nnsprod.com/git/oversu/Globinours [1.0.0]: https://nnsprod.com/git/oversu/Globinours
[0.1.0]: https://nnsprod.com/git/oversu/Globinours [0.1.0]: https://nnsprod.com/git/oversu/Globinours

View file

@ -2,7 +2,7 @@
Globinours est né d’un besoin de terrain : réunir dans une application légère les informations qu’un refuge utilise chaque jour, sans imposer une infrastructure complexe. Le projet s’est construit autour de PHP et SQLite afin de rester facile à installer, sauvegarder et déplacer. Globinours est né d’un besoin de terrain : réunir dans une application légère les informations qu’un refuge utilise chaque jour, sans imposer une infrastructure complexe. Le projet s’est construit autour de PHP et SQLite afin de rester facile à installer, sauvegarder et déplacer.
Les premières versions ont posé la fiche animale, le suivi médical, les placements, les adoptions, les documents et les tournées. L’application s’est ensuite enrichie d’un annuaire, de statistiques, d’outils administratifs, d’une pharmacie, d’un agenda partagé et d’un mini-site public séparant strictement les contenus publiés des informations internes. Les premières versions ont posé la fiche animale, le suivi médical, les placements, les adoptions, les documents et les tournées. L’application s’est ensuite enrichie d’un annuaire, de statistiques, d’outils administratifs, d’une pharmacie, d’un agenda partagé et d’un site public séparant strictement les contenus publiés des informations internes.
La sécurité et la continuité ont progressivement rejoint le cœur du projet : authentification, permissions, journal d’audit, médias privés hors de la racine web, sauvegardes vérifiées, restauration contrôlée, protection CSRF, limitation des connexions et paramètres adaptés aux reverse proxies de confiance. La sécurité et la continuité ont progressivement rejoint le cœur du projet : authentification, permissions, journal d’audit, médias privés hors de la racine web, sauvegardes vérifiées, restauration contrôlée, protection CSRF, limitation des connexions et paramètres adaptés aux reverse proxies de confiance.
@ -21,3 +21,7 @@ Recherche transversale respectant les permissions et poursuite du découpage du
## `1.0.0-rc.3` — Gel fonctionnel ## `1.0.0-rc.3` — Gel fonctionnel
Séparation des ressources visuelles et des routes, correction de l’installation vierge, jeu de démonstration enrichi et harmonisation de la présentation avant le gel fonctionnel de la version 1.0. Séparation des ressources visuelles et des routes, correction de l’installation vierge, jeu de démonstration enrichi et harmonisation de la présentation avant le gel fonctionnel de la version 1.0.
## `1.0.0-rc.4` — Retours de terrain
Correction des déplacements et des médias publics, amélioration du site public, ajout de filtres depuis les statistiques et extension des références biologiques à toutes les espèces configurées.

View file

@ -26,7 +26,7 @@ L’application est développée à partir de besoins rencontrés sur le terrain
- factures vétérinaires, règlements, justificatifs et statistiques de coûts ; - factures vétérinaires, règlements, justificatifs et statistiques de coûts ;
- registre des entrées et sorties, exports CSV et dossiers de subvention ; - registre des entrées et sorties, exports CSV et dossiers de subvention ;
- recherche globale avec contrôle des résultats selon le rôle ; - recherche globale avec contrôle des résultats selon le rôle ;
- mini-site public facultatif pour les animaux proposés à l’adoption ; - site public facultatif pour les animaux proposés à l’adoption ;
- import progressif depuis Animal Shelter Manager 3 (ASM3) ; - import progressif depuis Animal Shelter Manager 3 (ASM3) ;
- sauvegarde et restauration depuis l’interface d’administration ; - sauvegarde et restauration depuis l’interface d’administration ;
- interface française et anglaise. - interface française et anglaise.

View file

@ -1 +1 @@
1.0.0-rc.3 1.0.0-rc.4

View file

@ -13,6 +13,8 @@ trait AnimalBrowseActions
$quality = trim((string) ($_GET['quality'] ?? '')); $quality = trim((string) ($_GET['quality'] ?? ''));
$availability = trim((string) ($_GET['availability'] ?? '')); $availability = trim((string) ($_GET['availability'] ?? ''));
$species = SpeciesService::normalize((string) ($_GET['species'] ?? '')); $species = SpeciesService::normalize((string) ($_GET['species'] ?? ''));
$attribute = (string) ($_GET['attribute'] ?? '');
$attributeValue = trim((string) ($_GET['value'] ?? ''));
$sort = (string) ($_GET['sort'] ?? 'updated_at'); $sort = (string) ($_GET['sort'] ?? 'updated_at');
$dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC'; $dir = strtolower((string) ($_GET['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC';
$requestedView = (string) ($_GET['view'] ?? ''); $requestedView = (string) ($_GET['view'] ?? '');
@ -59,6 +61,20 @@ trait AnimalBrowseActions
} else { } else {
$species = ''; $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 !== '') { if ($q !== '') {
$where[] = '(a.name LIKE :q OR a.internal_code LIKE :q OR a.chip_id LIKE :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, 'availability' => $availability,
'viewMode' => $viewMode, 'viewMode' => $viewMode,
'species' => $species, 'species' => $species,
'attribute' => $attribute,
'attributeValue' => $attributeValue,
'speciesOptions' => SpeciesService::all(false), 'speciesOptions' => SpeciesService::all(false),
]); ]);
} }

View file

@ -486,7 +486,7 @@ final class AnimalsController
try { try {
$svc->refreshByChip($id, $chipId); $svc->refreshByChip($id, $chipId);
} catch (Throwable $e) { } 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); $cached = $svc->getCachedByAnimal($id);
$stale = $svc->isStale($cached, 86400); $stale = $svc->isStale($cached, 86400);

View file

@ -592,8 +592,26 @@ final class SettingsController
'groups' => $groups, 'groups' => $groups,
'saved' => isset($_GET['saved']), 'saved' => isset($_GET['saved']),
'error' => (string) ($_GET['error'] ?? ''), '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 public static function saveLabReferences(): void
{ {
self::admin(); self::admin();
@ -716,7 +734,7 @@ final class SettingsController
self::admin(); self::admin();
self::postOnly(); self::postOnly();
$theme = (string) ($_POST['public_site_theme'] ?? 'warm'); $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'; $theme = 'warm';
} }
$routing = (string) ($_POST['public_site_routing'] ?? 'integrated'); $routing = (string) ($_POST['public_site_routing'] ?? 'integrated');
@ -732,6 +750,7 @@ final class SettingsController
foreach ( foreach (
[ [
'public_site_home_title' => 150, 'public_site_home_title' => 150,
'public_site_name' => 150,
'public_site_home_text' => 5000, 'public_site_home_text' => 5000,
'public_site_about_title' => 150, 'public_site_about_title' => 150,
'public_site_about_text' => 10000, 'public_site_about_text' => 10000,
@ -745,7 +764,13 @@ final class SettingsController
) { ) {
$values[$key] = mb_substr(trim((string) ($_POST[$key] ?? '')), 0, $max); $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] ?? '')); $url = trim((string) ($_POST[$key] ?? ''));
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) { if ($url !== '' && (!filter_var($url, FILTER_VALIDATE_URL) || $scheme !== 'https')) {

View file

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

View file

@ -22,6 +22,8 @@ final class AppSettings
'app_language' => 'fr', 'app_language' => 'fr',
'public_site_enabled' => '0', 'public_site_enabled' => '0',
'public_site_theme' => 'warm', 'public_site_theme' => 'warm',
'public_site_name' => '',
'public_site_custom_css' => '',
'public_site_routing' => 'integrated', 'public_site_routing' => 'integrated',
'public_site_home_title' => 'Ils attendent une famille', 'public_site_home_title' => 'Ils attendent une famille',
'public_site_home_text' => 'Découvrez les animaux actuellement proposés à l’adoption par notre association.', '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_contact_text' => 'Une question sur une adoption ou sur l’association ? Contactez-nous.',
'public_site_facebook_url' => '', 'public_site_facebook_url' => '',
'public_site_instagram_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_enabled' => '0',
'notification_email_frequency' => 'weekly', 'notification_email_frequency' => 'weekly',
'notification_email_recipient' => '', 'notification_email_recipient' => '',

View file

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

View file

@ -33,4 +33,23 @@ final class LabReferenceService
} }
return $out; 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; 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')); 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 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')); throw new RuntimeException(t('service.private_media.not_found'));
} }
self::ensure($to); self::ensure($to, $toPublic);
if (!rename($source, $target)) { if (!rename($source, $target)) {
throw new RuntimeException(t('service.private_media.move_failed')); throw new RuntimeException(t('service.private_media.move_failed'));
} }
@ -55,6 +56,19 @@ final class PrivateMediaService
public static function migrateLegacy(): void public static function migrateLegacy(): void
{ {
$db = DB::pdo(); $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 $private = $db
->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0') ->query('SELECT animal_id,filename FROM animal_photos WHERE is_public=0')
->fetchAll(PDO::FETCH_ASSOC); ->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", "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, $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( $species = $rows(
$entered . $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, $p,
); );
$intakeTypes = $rows( $intakeTypes = $rows(
@ -680,6 +685,7 @@ final class StatisticsService
return [ return [
'colors' => $colors, 'colors' => $colors,
'breeds' => $breeds, 'breeds' => $breeds,
'hairTypes' => $hairTypes,
'species' => $species, 'species' => $species,
'intakeTypes' => $intakeTypes, 'intakeTypes' => $intakeTypes,
'intakeReasons' => $intakeReasons, 'intakeReasons' => $intakeReasons,

View file

@ -94,6 +94,22 @@ switch ($animal['sex']) {
text-decoration: none; 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> </style>
@ -429,7 +445,7 @@ $registrationClass = $registration === 'registered' ? 'bg-green-lt' : ($registra
<div class="mt-"> <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">🧬 <li class="nav-item"><a href="#tab-ident" class="nav-link active" data-bs-toggle="tab">🧬
<?= h(t('animal.tab.identity')) ?></a></li> <?= h(t('animal.tab.identity')) ?></a></li>
<li class="nav-item"><a href="#tab-activity" class="nav-link" data-bs-toggle="tab">📊 <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 ?? ''; $qualityLabel = $qualityLabel ?? '';
$species = $species ?? ''; $species = $species ?? '';
$speciesOptions = $speciesOptions ?? []; $speciesOptions = $speciesOptions ?? [];
$attribute = $attribute ?? '';
$attributeValue = $attributeValue ?? '';
$viewMode = $viewMode ?? 'table'; $viewMode = $viewMode ?? 'table';
$speciesByCode = []; $speciesByCode = [];
foreach ($speciesOptions as $speciesOption) { 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">
<div class="card-body"> <div class="card-body">
<form class="row g-2 align-items-center" method="get" action="/animals"> <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 ($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="sort" value="<?= h($sort) ?>">
<input type="hidden" name="dir" value="<?= h(strtolower($dir)) ?>"> <input type="hidden" name="dir" value="<?= h(strtolower($dir)) ?>">
<div class="col-12"> <div class="col-12">

View file

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

View file

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

View file

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

View file

@ -1,8 +1,9 @@
<?php <?php
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
$siteUrl = static fn (string$page = 'home', array$query = []): string => PublicSiteController::url($page, $query); $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'; $association = $settings['association_name'] ?: 'Association';
$siteName = trim((string)($settings['public_site_name'] ?? '')) ?: $association;
$viewFile = __DIR__ . '/' . $view; $viewFile = __DIR__ . '/' . $view;
$requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'), '/') ?: '/'; $requestPath = rtrim((string)(parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/'), '/') ?: '/';
$activePage = match($requestPath) { $activePage = match($requestPath) {
@ -38,13 +39,19 @@ if (!function_exists('public_age')) {
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= h($title . ' — ' . $association) ?></title> <title><?= h($title . ' — ' . $siteName) ?></title>
<meta name="description" <meta name="description"
content="<?= h(mb_substr(strip_tags((string)($settings['public_site_home_text'] ?? '')), 0, 155)) ?>"> content="<?= h(mb_substr(strip_tags((string)($settings['public_site_home_text'] ?? '')), 0, 155)) ?>">
<meta name="robots" <meta name="robots"
content="<?= ($settings['public_site_enabled'] ?? '0') === '1' ? 'index,follow' : 'noindex,nofollow' ?>"> content="<?= ($settings['public_site_enabled'] ?? '0') === '1' ? 'index,follow' : 'noindex,nofollow' ?>">
<?php if ($hasContactMap): ?> <?php if ($hasContactMap): ?>
<link rel="stylesheet" href="/assets/vendor/leaflet/leaflet.css"><?php endif; ?><style> <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 { :root {
--accent: #d87945; --accent: #d87945;
--accent-dark: #a84f29; --accent-dark: #a84f29;
@ -81,6 +88,20 @@ if (!function_exists('public_age')) {
--shadow: none --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 box-sizing: border-box
} }
@ -89,7 +110,7 @@ if (!function_exists('public_age')) {
margin: 0; margin: 0;
background: var(--paper); background: var(--paper);
color: var(--ink); 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 { a {
@ -432,7 +453,7 @@ if (!function_exists('public_age')) {
<header class="site-head"> <header class="site-head">
<div class="wrap head-row"><a class="brand" <div class="wrap head-row"><a class="brand"
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($association) ?></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()) ?>"> <a<?= $navClass($activePage === 'home') ?> href="<?= h($siteUrl()) ?>">
<?= h(t('public_site.home')) ?></a><?php foreach ($publicSpecies as $species): ?> <?= 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> : <?= h($settings['association_siret']) ?></span><?php endif; ?></div>
</div> </div>
<div class="footer-powered"><?= h(t('public_site.powered_by')) ?> <a <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> rel="noopener noreferrer">Globinours</a></div>
</div> </div>
</footer> </footer>

View file

@ -2,7 +2,7 @@
<div class="wrap"> <div class="wrap">
<article class="content-card"> <article class="content-card">
<h1 class="page-title"><?= h($title) ?></h1> <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 <?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']) ?>" 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; ?> 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; ?> <?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> <div class="alert alert-info"><strong><?= h(t('lab_reference.priority_title')) ?></strong>
<?= h(t('lab_reference.priority_help')) ?></div> <?= 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" <form method="post" action="/settings/lab-references/save"><input type="hidden" name="csrf"
value="<?= h(Auth::csrf()) ?>"> value="<?= h(Auth::csrf()) ?>">
<?php foreach ($groups as $analyzer => $speciesGroups): ?><div class="card mb-3"> <?php foreach ($groups as $analyzer => $speciesGroups): ?><div class="card mb-3">

View file

@ -42,13 +42,20 @@
</div> </div>
<div class="col-12"><label class="form-label"><?= h(t('public_site.theme')) ?></label> <div class="col-12"><label class="form-label"><?= h(t('public_site.theme')) ?></label>
<div class="row g-2"> <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): ?> <?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-4"><label class="form-selectgroup-item w-100"><input type="radio" <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" name="public_site_theme" value="<?= h($key) ?>" class="form-selectgroup-input"
<?= ($settings['public_site_theme'] ?? 'warm') === $key ? 'checked' : '' ?>><span <?= ($settings['public_site_theme'] ?? 'warm') === $key ? 'checked' : '' ?>><span
class="form-selectgroup-label d-flex align-items-center gap-2"><?= $theme[0] ?> class="form-selectgroup-label d-flex align-items-center gap-2"><?= $theme[0] ?>
<strong><?= h($theme[1]) ?></strong></span></label></div><?php endforeach; ?></div> <strong><?= h($theme[1]) ?></strong></span></label></div><?php endforeach; ?></div>
</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> </div>
</div> </div>
@ -60,7 +67,7 @@
<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>
<div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea <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> rows="4"><?= h($settings['public_site_home_text']) ?></textarea></div>
</div> </div>
</div> </div>
@ -77,7 +84,7 @@
class="form-control" name="public_site_about_title" class="form-control" name="public_site_about_title"
value="<?= h($settings['public_site_about_title']) ?>"></div> value="<?= h($settings['public_site_about_title']) ?>"></div>
<div class="col-md-8"><label class="form-label"><?= h(t('common.text')) ?></label><textarea <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> rows="5"><?= h($settings['public_site_about_text']) ?></textarea></div>
</div> </div>
</div> </div>
@ -93,7 +100,7 @@
class="form-control" type="url" name="public_site_donation_url" class="form-control" type="url" name="public_site_donation_url"
value="<?= h($settings['public_site_donation_url']) ?>" placeholder="https://..."></div> value="<?= h($settings['public_site_donation_url']) ?>" placeholder="https://..."></div>
<div class="col-12"><label class="form-label"><?= h(t('common.text')) ?></label><textarea <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> rows="4"><?= h($settings['public_site_donation_text']) ?></textarea></div>
</div> </div>
</div> </div>
@ -104,13 +111,18 @@
<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
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> name="public_site_contact_text" rows="3"><?= h($settings['public_site_contact_text']) ?></textarea>
</div> </div>
<div class="col-md-6"><label class="form-label">Facebook</label><input class="form-control" type="url" <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> 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" <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> 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> </div>
<div class="card mb-3"> <div class="card mb-3">
@ -129,3 +141,25 @@
</div> </div>
<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>
<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); $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')]; $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 = [ $groups = [
[t('statistics.intake_modes'),$distributions['intakeTypes'],$intakeTypeLabels,'green'], [t('statistics.intake_modes'),$distributions['intakeTypes'],$intakeTypeLabels,'green'],
[t('statistics.intake_reasons'),$distributions['intakeReasons'],$intakeReasonLabels,'orange'], [t('statistics.intake_reasons'),$distributions['intakeReasons'],$intakeReasonLabels,'orange'],
[t('statistics.colors'),$distributions['colors'],[],'blue'], [t('statistics.colors'),$distributions['colors'],[],'blue','color'],
[t('statistics.breeds'),$distributions['breeds'],[],'purple'], [t('statistics.breeds'),$distributions['breeds'],[],'purple','breed'],
[t('statistics.species'),$distributions['species'],[],'cyan'], [t('statistics.hair_types'),$distributions['hairTypes'] ?? [],[],'azure','hair_type'],
[t('statistics.sex'),$distributions['sex'],[],'pink'], [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.age_at_entry'),$distributions['ages'],[],'orange'],
[t('statistics.origins'),$distributions['origins'],[],'green','municipality'], [t('statistics.origins'),$distributions['origins'],[],'green','municipality'],
[t('statistics.adoption_delay'),$distributions['stays'],[],'azure'], [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; <?php foreach ($items as $label => $total): $display = $labels[$label] ?? $distributionLabels[$label] ?? $label;
$width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0; $width = $maximum > 0 ? max(4, round($total / $maximum * 100)) : 0;
$pct = $sum > 0 ? round($total / $sum * 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"> <div class="d-flex justify-content-between gap-3 mb-1">
<?php if ($linkType === 'municipality' && $municipality === ''): ?><a <?php if ($linkType === 'municipality' && $municipality === ''): ?><a
class="text-truncate fw-semibold" href="<?= h($cityUrl) ?>" 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" class="text-truncate"
title="<?= h($display) ?>"><?= h($display) ?></span><?php endif; ?><strong title="<?= h($display) ?>"><?= h($display) ?></span><?php endif; ?><strong
class="text-nowrap"><?= (int)$total ?> <span class="text-nowrap"><?= (int)$total ?> <span

View file

@ -41,7 +41,7 @@ Le menu **Ajouter** centralise les événements structurés. Le menu **Actions**
## Photos et visibilité ## Photos et visibilité
Depuis l’onglet **Photos**, téléversez une ou plusieurs images, choisissez la photo principale et définissez leur visibilité. Les photos privées restent réservées à l’application. Une photo publique peut apparaître sur le mini-site si l’animal lui-même est publié. Depuis l’onglet **Photos**, téléversez une ou plusieurs images, choisissez la photo principale et définissez leur visibilité. Les photos privées restent réservées à l’application. Une photo publique peut apparaître sur le site public si l’animal lui-même est publié.
Lorsque les outils d’image du serveur sont disponibles, Globinours réoriente, optimise et retire les métadonnées des images. Vérifiez néanmoins qu’aucune personne, adresse, ordonnance ou information sensible n’est visible. Lorsque les outils d’image du serveur sont disponibles, Globinours réoriente, optimise et retire les métadonnées des images. Vérifiez néanmoins qu’aucune personne, adresse, ordonnance ou information sensible n’est visible.

View file

@ -2,7 +2,7 @@
[← Portées et liens](03-portees-et-liens.md) · [Sommaire](README.md) · [Tournées →](05-tournees.md) [← Portées et liens](03-portees-et-liens.md) · [Sommaire](README.md) · [Tournées →](05-tournees.md)
> Le module médical peut être masqué par les permissions. Les données de ce chapitre sont privées et ne sont jamais destinées au mini-site. > Le module médical peut être masqué par les permissions. Les données de ce chapitre sont privées et ne sont jamais destinées au site public.
## Aperçu médical ## Aperçu médical

View file

@ -1,4 +1,4 @@
# 8. Documents et mini-site public # 8. Documents et site public
[← Statistiques et administratif](07-statistiques-et-administratif.md) · [Sommaire](README.md) · [Utilisateurs et RGPD →](09-utilisateurs-et-rgpd.md) [← Statistiques et administratif](07-statistiques-et-administratif.md) · [Sommaire](README.md) · [Utilisateurs et RGPD →](09-utilisateurs-et-rgpd.md)
@ -12,7 +12,7 @@ Dans la fiche animale, ouvrez **Plus → Documents**. Les contrats et formulaire
Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML est destinée à la visualisation ou l’impression. Les documents générés portent une mention de génération afin d’identifier leur date et leur origine. Le fichier ODT reste modifiable dans une suite bureautique. La version PDF/HTML est destinée à la visualisation ou l’impression. Les documents générés portent une mention de génération afin d’identifier leur date et leur origine.
## Activer le mini-site ## 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.
@ -26,7 +26,7 @@ Deux modes existent :
Une fiche publique apparaît uniquement si l’animal est à la fois : Une fiche publique apparaît uniquement si l’animal est à la fois :
1. disponible à l’adoption ; 1. disponible à l’adoption ;
2. autorisé à être publié sur le mini-site. 2. autorisé à être publié sur le site public.
Au moins une photo doit être publique pour illustrer correctement la fiche. Les données privées — identification, médical, lieux internes, coordonnées de FA, notes et documents — ne sont jamais chargées par les pages publiques. Au moins une photo doit être publique pour illustrer correctement la fiche. Les données privées — identification, médical, lieux internes, coordonnées de FA, notes et documents — ne sont jamais chargées par les pages publiques.

View file

@ -6,7 +6,7 @@ La roue crantée et la barre interne utilisent les mêmes catégories : **Config
## Paramètres généraux ## Paramètres généraux
Renseignez l’identité de l’association : nom, coordonnées, adresse, RNA, SIRET, logo, langue et tarifs d’adoption. Ces valeurs alimentent l’interface, le mini-site et les documents générés. Renseignez l’identité de l’association : nom, coordonnées, adresse, RNA, SIRET, logo, langue et tarifs d’adoption. Ces valeurs alimentent l’interface, le site public et les documents générés.
Le logo de l’association est une donnée propre à l’installation. Le logo Globinours reste l’identité du logiciel. Le logo de l’association est une donnée propre à l’installation. Le logo Globinours reste l’identité du logiciel.
@ -28,7 +28,7 @@ Une absence d’intervalle constructeur doit rester vide. Ne créez pas une plag
## Site public ## Site public
Cette rubrique gère l’activation, le mode d’URL, le thème, les textes et les pages légales. Consultez le chapitre [Documents et mini-site public](08-documents-et-site-public.md) avant la première publication. Cette rubrique gère l’activation, le mode d’URL, le thème, les textes et les pages légales. Consultez le chapitre [Documents et site public](08-documents-et-site-public.md) avant la première publication.
## Santé et notifications ## Santé et notifications

View file

@ -50,7 +50,7 @@ Ne copiez jamais une base réelle, un `.env`, les médias privés ou une sauvega
- affichage d’une photo privée autorisée ; - affichage d’une photo privée autorisée ;
- création puis suppression d’une donnée de test si le contexte le permet ; - création puis suppression d’une donnée de test si le contexte le permet ;
- sauvegarde téléchargeable ; - sauvegarde téléchargeable ;
- mini-site public sans information privée. - site public sans information privée.
--- ---

View file

@ -25,7 +25,7 @@ Les emplacements suivants peuvent contenir des informations personnelles ou méd
| `data/association/` | Identité et logo de l’association | | `data/association/` | Identité et logo de l’association |
| `data/private-media/` | Photos privées | | `data/private-media/` | Photos privées |
| `data/medical-documents/` | Ordonnances et résultats médicaux | | `data/medical-documents/` | Ordonnances et résultats médicaux |
| `public/media/` | Images destinées à l’interface ou au mini-site | | `public/media/` | Images destinées à l’interface ou au site public |
| `storage/backups/` | Sauvegardes complètes | | `storage/backups/` | Sauvegardes complètes |
| `storage/logs/` | Journaux applicatifs | | `storage/logs/` | Journaux applicatifs |
| `storage/asm3-imports/` | Sources et rapports d’import ASM3 | | `storage/asm3-imports/` | Sources et rapports d’import ASM3 |

View file

@ -18,7 +18,7 @@ Ce manuel décrit l’interface telle qu’elle existe actuellement. Il peut êt
6. [Agenda partagé](14-agenda.md) — rendez-vous, transports, visites, tâches, responsables et rappels. 6. [Agenda partagé](14-agenda.md) — rendez-vous, transports, visites, tâches, responsables et rappels.
7. [Annuaire](06-annuaire.md) — personnes, structures, familles d’accueil, adoptants et partenaires. 7. [Annuaire](06-annuaire.md) — personnes, structures, familles d’accueil, adoptants et partenaires.
8. [Statistiques et subventions](07-statistiques-et-administratif.md) — indicateurs, coûts, communes, registre, exports et dossiers annuels. 8. [Statistiques et subventions](07-statistiques-et-administratif.md) — indicateurs, coûts, communes, registre, exports et dossiers annuels.
9. [Documents et mini-site public](08-documents-et-site-public.md). 9. [Documents et site public](08-documents-et-site-public.md).
### Administrer l’installation ### Administrer l’installation
@ -41,7 +41,7 @@ Ce manuel décrit l’interface telle qu’elle existe actuellement. Il peut êt
- Les chemins tels que **Paramètres → Sauvegardes** désignent les menus à ouvrir successivement. - Les chemins tels que **Paramètres → Sauvegardes** désignent les menus à ouvrir successivement.
- Les libellés peuvent légèrement varier si l’interface est configurée en anglais. - Les libellés peuvent légèrement varier si l’interface est configurée en anglais.
- Une action sensible signalée par Globinours doit être précédée d’une sauvegarde et relue avant confirmation. - Une action sensible signalée par Globinours doit être précédée d’une sauvegarde et relue avant confirmation.
- Les informations médicales et personnelles ne doivent jamais être copiées vers le mini-site public. - Les informations médicales et personnelles ne doivent jamais être copiées vers le site public.
## Aide et contribution ## Aide et contribution

View file

@ -1,4 +1,14 @@
/* Globinours — structure visuelle commune */ /* Globinours — structure visuelle commune */
@font-face {
font-family: "Globinours Emoji";
src: url("/assets/fonts/globinours-emoji.woff2") format("woff2");
font-display: swap;
}
body {
font-family: "Globinours Emoji", system-ui, -apple-system, "Segoe UI", sans-serif;
}
.coat-swatch { .coat-swatch {
display:inline-block; display:inline-block;
width:1rem; width:1rem;
@ -248,4 +258,3 @@ body,
} }
/****** END CUSTOM *****/ /****** END CUSTOM *****/

Binary file not shown.

View file

@ -14,6 +14,7 @@ require_once __DIR__ . '/../app/Services/PermissionService.php';
require_once __DIR__ . '/../app/Services/AuditService.php'; require_once __DIR__ . '/../app/Services/AuditService.php';
require_once __DIR__ . '/../app/Services/BackupService.php'; require_once __DIR__ . '/../app/Services/BackupService.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/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';

View file

@ -119,4 +119,5 @@ return [
'accounting.document_format'=>'Allowed formats: PDF, JPEG, PNG and WebP.', 'accounting.document_format'=>'Allowed formats: PDF, JPEG, PNG and WebP.',
'accounting.document_save_failed'=>'The document could not be saved.', 'accounting.document_save_failed'=>'The document could not be saved.',
'search.title'=>'Global search','search.placeholder'=>'Animal, contact, appointment or invoice…','search.action'=>'Search','search.hint'=>'Enter at least two characters.','search.none'=>'No accessible result.','search.open'=>'Open global search','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animals','search.group.directory'=>'Directory','search.group.agenda'=>'Calendar','search.group.accounting'=>'Accounting','search.loading'=>'Searching…','search.all_results'=>'Show all results','search.error'=>'Search is temporarily unavailable.', 'search.title'=>'Global search','search.placeholder'=>'Animal, contact, appointment or invoice…','search.action'=>'Search','search.hint'=>'Enter at least two characters.','search.none'=>'No accessible result.','search.open'=>'Open global search','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animals','search.group.directory'=>'Directory','search.group.agenda'=>'Calendar','search.group.accounting'=>'Accounting','search.loading'=>'Searching…','search.all_results'=>'Show all results','search.error'=>'Search is temporarily unavailable.',
'public_site.settings_heading'=>'Public website','public_site.theme_custom'=>'Custom','public_site.site_name'=>'Website name','public_site.custom_css'=>'Custom CSS','public_site.custom_css_help'=>'Used by the custom theme. Imports and external resources are blocked.','public_site.invalid_css'=>'The stylesheet contains a forbidden instruction.','public_site.routing_management_help'=>'Management stays at / and the public website at /site.','public_site.publish_animal'=>'Publish this animal on the public website','public_site.publish_animal_help'=>'The profile will only be visible when the animal is also available for adoption. Only public photos and the text below are used.','lab_reference.add_species'=>'Add a species to lab reports','lab_reference.copy_from'=>'Copy reference ranges from','lab_reference.target_species'=>'New species','lab_reference.copy_help'=>'Ranges are copied as a starting point and remain editable.','lab_reference.invalid_species'=>'Choose two different valid species.','lab_reference.nothing_copied'=>'This species already has these reference ranges.','statistics.hair_types'=>'Hair types','animals.filtered_by'=>'Filter: :value','service.icad.home_unavailable'=>'The I-CAD website is unavailable','service.icad.search_unavailable'=>'I-CAD search is unavailable','service.icad.record_unavailable'=>'The I-CAD record is unavailable',
]; ];

View file

@ -119,4 +119,5 @@ return [
'accounting.document_format'=>'Formats autorisés : PDF, JPEG, PNG et WebP.', 'accounting.document_format'=>'Formats autorisés : PDF, JPEG, PNG et WebP.',
'accounting.document_save_failed'=>'Impossible d’enregistrer le document.', 'accounting.document_save_failed'=>'Impossible d’enregistrer le document.',
'search.title'=>'Recherche globale','search.placeholder'=>'Animal, contact, rendez-vous ou facture…','search.action'=>'Rechercher','search.hint'=>'Saisissez au moins deux caractères.','search.none'=>'Aucun résultat accessible.','search.open'=>'Ouvrir la recherche globale','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animaux','search.group.directory'=>'Annuaire','search.group.agenda'=>'Agenda','search.group.accounting'=>'Comptabilité','search.loading'=>'Recherche…','search.all_results'=>'Afficher tous les résultats','search.error'=>'La recherche est momentanément indisponible.', 'search.title'=>'Recherche globale','search.placeholder'=>'Animal, contact, rendez-vous ou facture…','search.action'=>'Rechercher','search.hint'=>'Saisissez au moins deux caractères.','search.none'=>'Aucun résultat accessible.','search.open'=>'Ouvrir la recherche globale','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animaux','search.group.directory'=>'Annuaire','search.group.agenda'=>'Agenda','search.group.accounting'=>'Comptabilité','search.loading'=>'Recherche…','search.all_results'=>'Afficher tous les résultats','search.error'=>'La recherche est momentanément indisponible.',
'public_site.settings_heading'=>'Site public','public_site.theme_custom'=>'Personnalisé','public_site.site_name'=>'Nom du site','public_site.custom_css'=>'CSS personnalisé','public_site.custom_css_help'=>'Actif avec le thème personnalisé. Les imports et les ressources externes sont bloqués.','public_site.invalid_css'=>'La feuille de style contient une instruction interdite.','public_site.routing_management_help'=>'La gestion reste sur / et le site public sur /site.','public_site.publish_animal'=>'Publier cet animal sur le site public','public_site.publish_animal_help'=>'La fiche ne sera visible que si l’animal est également disponible à l’adoption. Seules les photos publiques et le texte ci-dessous seront utilisés.','lab_reference.add_species'=>'Ajouter une espèce aux bilans','lab_reference.copy_from'=>'Copier les références de','lab_reference.target_species'=>'Nouvelle espèce','lab_reference.copy_help'=>'Les intervalles sont copiés comme point de départ, puis restent modifiables.','lab_reference.invalid_species'=>'Choisissez deux espèces différentes et valides.','lab_reference.nothing_copied'=>'Cette espèce possède déjà ces références.','statistics.hair_types'=>'Types de poil','animals.filtered_by'=>'Filtre : :value','service.icad.home_unavailable'=>'Le site I-CAD est indisponible','service.icad.search_unavailable'=>'La recherche I-CAD est indisponible','service.icad.record_unavailable'=>'La fiche I-CAD est indisponible',
]; ];

View file

@ -26,6 +26,9 @@ foreach (
'NotificationService', 'NotificationService',
'PrivacyService', 'PrivacyService',
'StatisticsService', 'StatisticsService',
'SpeciesService',
'LabReferenceService',
'PublicContentFormatter',
'ShelterRoomService', 'ShelterRoomService',
'GlobalSearchService', 'GlobalSearchService',
'PrivateMediaService', 'PrivateMediaService',
@ -134,6 +137,18 @@ try {
->fetchColumn() === 'Bilan complet', ->fetchColumn() === 'Bilan complet',
); );
}); });
$test('Une espèce peut recevoir un référentiel biologique modifiable', function () use ($db, $assert): void {
$copied = LabReferenceService::copySpecies('chat', 'lapin', null);
$assert($copied > 0, 'Aucune référence copiée');
$count = (int) $db->query("SELECT COUNT(*) FROM lab_reference_ranges WHERE species_code='lapin'")->fetchColumn();
$assert($count === $copied, 'Références incomplètes');
});
$test('La mise en forme du site public échappe le HTML', function () use ($assert): void {
$html = PublicContentFormatter::render("## Titre\n- **Texte**\n<script>alert(1)</script>");
$assert(str_contains($html, '<h3>Titre</h3>'));
$assert(str_contains($html, '<strong>Texte</strong>'));
$assert(!str_contains($html, '<script>'), 'HTML brut non échappé');
});
$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);