Préparer la version 1.0.0-rc.4
This commit is contained in:
parent
0a482afe9d
commit
6e3beea211
38 changed files with 388 additions and 56 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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/).
|
||||
|
||||
## [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
|
||||
|
||||
### 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 ;
|
||||
- comptes, rôles, permissions, audit et sauvegardes ;
|
||||
- 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
|
||||
[0.1.0]: https://nnsprod.com/git/oversu/Globinours
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -21,3 +21,7 @@ Recherche transversale respectant les permissions et poursuite du découpage du
|
|||
## `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.
|
||||
|
||||
## `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.
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
- registre des entrées et sorties, exports CSV et dossiers de subvention ;
|
||||
- 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) ;
|
||||
- sauvegarde et restauration depuis l’interface d’administration ;
|
||||
- interface française et anglaise.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.0-rc.3
|
||||
1.0.0-rc.4
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' => '',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
67
app/Services/PublicContentFormatter.php
Normal file
67
app/Services/PublicContentFormatter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">📊
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; ?>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Le menu **Ajouter** centralise les événements structurés. Le menu **Actions**
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[← 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ Deux modes existent :
|
|||
Une fiche publique apparaît uniquement si l’animal est à la fois :
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ La roue crantée et la barre interne utilisent les mêmes catégories : **Config
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ Une absence d’intervalle constructeur doit rester vide. Ne créez pas une plag
|
|||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
- création puis suppression d’une donnée de test si le contexte le permet ;
|
||||
- sauvegarde téléchargeable ;
|
||||
- mini-site public sans information privée.
|
||||
- site public sans information privée.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ Les emplacements suivants peuvent contenir des informations personnelles ou méd
|
|||
| `data/association/` | Identité et logo de l’association |
|
||||
| `data/private-media/` | Photos privées |
|
||||
| `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/logs/` | Journaux applicatifs |
|
||||
| `storage/asm3-imports/` | Sources et rapports d’import ASM3 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
|
@ -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 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.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
/* 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 {
|
||||
display:inline-block;
|
||||
width:1rem;
|
||||
|
|
@ -248,4 +258,3 @@ body,
|
|||
}
|
||||
|
||||
/****** END CUSTOM *****/
|
||||
|
||||
|
|
|
|||
BIN
public/assets/fonts/globinours-emoji.woff2
Normal file
BIN
public/assets/fonts/globinours-emoji.woff2
Normal file
Binary file not shown.
|
|
@ -14,6 +14,7 @@ require_once __DIR__ . '/../app/Services/PermissionService.php';
|
|||
require_once __DIR__ . '/../app/Services/AuditService.php';
|
||||
require_once __DIR__ . '/../app/Services/BackupService.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/SystemHealthService.php';
|
||||
require_once __DIR__ . '/../app/Services/NotificationService.php';
|
||||
|
|
|
|||
|
|
@ -119,4 +119,5 @@ return [
|
|||
'accounting.document_format'=>'Allowed formats: PDF, JPEG, PNG and WebP.',
|
||||
'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.',
|
||||
'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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -119,4 +119,5 @@ return [
|
|||
'accounting.document_format'=>'Formats autorisés : PDF, JPEG, PNG et WebP.',
|
||||
'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.',
|
||||
'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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ foreach (
|
|||
'NotificationService',
|
||||
'PrivacyService',
|
||||
'StatisticsService',
|
||||
'SpeciesService',
|
||||
'LabReferenceService',
|
||||
'PublicContentFormatter',
|
||||
'ShelterRoomService',
|
||||
'GlobalSearchService',
|
||||
'PrivateMediaService',
|
||||
|
|
@ -134,6 +137,18 @@ try {
|
|||
->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 {
|
||||
$assert(ini_get('session.use_strict_mode') === '1', 'Le mode strict des sessions est désactivé');
|
||||
$hash = password_hash('MotDePasseSolide!', PASSWORD_DEFAULT);
|
||||
|
|
|
|||
Loading…
Reference in a new issue