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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue