Publier Globinours 1.0.0-rc.3

This commit is contained in:
Alexandre NOEL 2026-09-03 12:39:15 +02:00
commit 9a2b4068da
325 changed files with 38230 additions and 20 deletions

382
app/Views/layout.php Normal file
View file

@ -0,0 +1,382 @@
<?php
/** @var string $title */
require_once __DIR__ . '/_helpers.php';
require_once __DIR__ . '/_settings_links.php';
$hasRescue = $hasRescue ?? false;
$hasCurrent = $hasCurrent ?? false;
$rescueAddr = $rescueAddr ?? null;
$currentAddr = $currentAddr ?? null;
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$dashboardActive = $currentPath === '/' || $currentPath === '/dashboard';
$statisticsActive = $currentPath === '/statistics';
$animalsActive = $currentPath === '/animals' || str_starts_with($currentPath, '/animal');
$adminActive = str_starts_with($currentPath, '/admin');
$directoryActive = str_starts_with($currentPath, '/directory');
$careRoundActive = str_starts_with($currentPath, '/care-round');
$agendaActive = str_starts_with($currentPath, '/agenda');
$accountingActive = str_starts_with($currentPath, '/accounting');
$settingsActive = str_starts_with($currentPath, '/settings');
$settingsMenuLinks = settingsNavigationLinks();
$currentUser = Auth::user();
$pageName = trim((string)($title ?? ''));
$documentTitle = $pageName !== '' ? $pageName . ' — Globinours' : 'Globinours';
$pageDescription = trim((string)($pageDescription ?? ''));
if ($pageDescription === '') {
$pageDescription = match (true) {
$dashboardActive => t('meta.dashboard'),
$statisticsActive => t('meta.statistics'),
$adminActive => t('meta.administrative'),
$animalsActive => t('meta.animals'),
default => t('meta.default'),
};
}
?>
<!doctype html>
<html lang="<?= h(I18n::locale()) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($documentTitle) ?></title>
<meta name="description" content="<?= h($pageDescription) ?>">
<meta name="application-name" content="Globinours">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#206bc4">
<meta name="csrf-token" content="<?= h(Auth::csrf()) ?>">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="stylesheet" href="/assets/mobile.css">
<link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css">
<link rel="stylesheet" href="/assets/vendor/flatpickr/flatpickr.min.css">
<link rel="stylesheet" href="/assets/app-shell.css">
<!-- Chart.js (pour les courbes poids) -->
<script src="/assets/vendor/chartjs/chart.umd.min.js"></script>
</head>
<body>
<div class="page">
<?php require __DIR__ . '/_layout_topbar.php'; ?>
<div class="page-wrapper">
<div class="page-header d-print-none">
<div class="container-xl">
<h2 class="page-title"><?= htmlspecialchars($title ?? '', ENT_QUOTES) ?></h2>
</div>
</div>
<div class="page-body">
<div class="container-xl">
<?php require $viewFile; ?>
</div>
</div>
<?php require __DIR__ . '/_layout_footer.php'; ?>
</div>
</div>
<script src="/assets/vendor/tabler/tabler.min.js"></script>
<link rel="stylesheet" href="/assets/vendor/leaflet/leaflet.css">
<script src="/assets/vendor/leaflet/leaflet.js"></script>
<script>
(function() {
const rescue = {
lat: < ? = $hasRescue ? (float) $rescueLat : 'null' ? > ,
lng: < ? = $hasRescue ? (float) $rescueLng : 'null' ? > ,
addr: < ? = json_encode($rescueAddr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ? >
};
const cur = {
lat: < ? = $hasCurrent ? (float) $currentLat : 'null' ? > ,
lng: < ? = $hasCurrent ? (float) $currentLng : 'null' ? > ,
addr: < ? = json_encode($currentAddr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ? >
};
const el = document.getElementById('mapJourney');
if (!el || typeof L === 'undefined') return;
const fallback = [48.5, -4.3];
// icônes “pastille” colorées
const iconRescue = L.divIcon({
className: '',
iconSize: [18, 18],
iconAnchor: [9, 9],
html: '<div class="twix-pin rescue" title="' + < ? = json_encode(t('map.rescue'),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ? > +'"></div>'
});
const iconCurrent = L.divIcon({
className: '',
iconSize: [18, 18],
iconAnchor: [9, 9],
html: '<div class="twix-pin current" title="' + < ? = json_encode(t('map.current'),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ? > +'"></div>'
});
const map = L.map('mapJourney', {
scrollWheelZoom: false
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a>'
}).addTo(map);
const pts = [];
// helper anti XSS + joli rendu
const esc = (s) => String(s ?? '')
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", "&#039;");
const safePopup = (label, address) => {
const box = document.createElement('div');
const strong = document.createElement('strong');
strong.textContent = label;
box.append(strong, document.createElement('br'), document.createTextNode(address || '—'));
return box;
};
if (rescue.lat !== null && rescue.lng !== null) {
const p = [rescue.lat, rescue.lng];
pts.push(p);
L.marker(p, {
icon: iconRescue
})
.addTo(map)
.bindPopup(safePopup( < ? = json_encode(t('map.rescue'), JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES) ? > , rescue.addr), {
autoPan: false, // <-- stop le déplacement
closeButton: false
});
}
if (cur.lat !== null && cur.lng !== null) {
const p = [cur.lat, cur.lng];
pts.push(p);
L.marker(p, {
icon: iconCurrent
})
.addTo(map)
.bindPopup(safePopup( < ? = json_encode(t('map.current'), JSON_UNESCAPED_UNICODE |
JSON_UNESCAPED_SLASHES) ? > , cur.addr), {
autoPan: false, // <-- stop le déplacement
closeButton: false
});
}
if (pts.length === 0) {
map.setView(fallback, 8);
L.popup({
autoPan: false
}).setLatLng(fallback).setContent( < ? = json_encode(t('map.missing_coordinates'),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ? > ).openOn(map);
} else if (pts.length === 1) {
map.setView(pts[0], 14);
} else {
// “vol doiseau” : ligne + fitBounds avec padding pour éviter les pins collés au bord
L.polyline(pts, {
weight: 3,
opacity: 0.8,
dashArray: "6 6"
}).addTo(map);
map.fitBounds(L.latLngBounds(pts), {
padding: [60, 60]
}); // <-- plus de marge => popup moins au bord
}
// Légende (dans la carte)
const legend = L.control({
position: 'topright'
});
legend.onAdd = function() {
const div = L.DomUtil.create('div', 'twix-map-legend');
div.innerHTML = `
<div class="row"><span class="dot rescue"></span> ${<?= json_encode(t('map.rescue'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>}</div>
<div class="row"><span class="dot current"></span> ${<?= json_encode(t('map.current'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>}</div>
<div class="row" style="opacity:.9"><span class="line"></span> ${<?= json_encode(t('map.flight_path'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>}</div>
`;
return div;
};
legend.addTo(map);
// Leaflet + tabs : fix affichage
setTimeout(() => map.invalidateSize(), 200);
})();
</script>
<script src="/assets/vendor/flatpickr/flatpickr.min.js"></script>
<script src="/assets/vendor/flatpickr/l10n/fr.js"></script>
<script defer src="/assets/pwa.js"></script>
<script src="/assets/global-search.js"></script>
<script>
const settingsMenu = document.querySelector('.settings-dropdown .dropdown-menu');
if (settingsMenu) {
const groupHeaders = [...settingsMenu.children].filter(node => node.classList.contains('dropdown-header'));
if (groupHeaders.length === 4) {
const groups = groupHeaders.map(header => {
const group = document.createElement('div');
group.className = 'settings-menu-group';
const nodes = [header];
let next = header.nextElementSibling;
while (next && !next.classList.contains('dropdown-divider') && !next.classList.contains(
'dropdown-header') && next.tagName !== 'FORM') {
nodes.push(next);
next = next.nextElementSibling;
}
nodes.forEach(node => group.appendChild(node));
return group;
});
const logout = settingsMenu.querySelector(':scope > form[action="/logout"]');
const logoutDivider = logout?.previousElementSibling?.classList.contains('dropdown-divider') ? logout
.previousElementSibling : null;
[...settingsMenu.querySelectorAll(':scope > .dropdown-divider')].forEach((divider, index) => {
if (index > 0 && divider !== logoutDivider) divider.remove();
});
const columns = document.createElement('div');
columns.className = 'settings-menu-columns';
const configuration = document.createElement('div');
configuration.className = 'settings-menu-column';
configuration.append(groups[0], groups[1]);
const administration = document.createElement('div');
administration.className = 'settings-menu-column';
administration.append(groups[2], groups[3]);
columns.append(configuration, administration);
settingsMenu.insertBefore(columns, logoutDivider || logout || null);
settingsMenu.classList.add('settings-menu-wide');
}
}
const globinoursPermissions = < ? = json_encode(PermissionService::currentPermissions(),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ? > ;
const permissionModuleForPath = path => {
if (path === '/' || path.startsWith('/dashboard')) return 'dashboard';
if (path.startsWith('/animal/documents')) return 'medical';
if (path.startsWith('/animals') || path.startsWith('/animal') || path.startsWith('/litter') || path
.startsWith('/bonded')) {
if (['/animal/add-medical', '/animal/medical-status', '/animal/add-treatment',
'/animal/add-vaccine', '/animal/death', '/animal/surgery'
].some(prefix => path.startsWith(prefix))) return 'medical';
return 'animals';
}
if (path.startsWith('/care-round')) return 'care';
if (path.startsWith('/directory')) return 'directory';
if (path.startsWith('/statistics')) return 'statistics';
if (path.startsWith('/accounting')) return 'accounting';
if (path.startsWith('/admin/grants')) return 'grants';
if (path.startsWith('/admin') || path.startsWith('/documents')) return 'administrative';
return null;
};
const permissionEditPages = new Set(['/animal/new', '/animal/edit', '/animal/death', '/litter/new',
'/litter/add-existing', '/litter/add-existing/save', '/bonded/new', '/directory/new',
'/directory/edit', '/care-round/setup'
]);
const permissionAllows = (url, action = 'view') => {
let path;
try {
path = new URL(url, location.origin).pathname;
} catch {
return true;
}
const module = permissionModuleForPath(path);
if (!module) return true;
if (action === 'view' && permissionEditPages.has(path)) action = 'edit';
return Boolean(globinoursPermissions[module]?.[action]);
};
document.querySelectorAll('.page-body form').forEach(form => {
if ((form.method || 'get').toLowerCase() !== 'post') return;
if (!permissionAllows(form.action, 'edit')) {
form.hidden = true;
form.dataset.permissionHidden = 'true';
}
});
document.querySelectorAll('.page-body a[href]').forEach(link => {
const raw = link.getAttribute('href') || '';
if (raw.startsWith('#') || raw.startsWith('javascript:')) return;
if (!permissionAllows(link.href, 'view')) {
link.hidden = true;
link.dataset.permissionHidden = 'true';
}
});
document.querySelectorAll('.page-body [data-bs-target]').forEach(trigger => {
const target = document.querySelector(trigger.getAttribute('data-bs-target'));
const actions = target ? [...target.querySelectorAll('form')].map(form => form.action) : [];
if (actions.length && actions.every(action => !permissionAllows(action, 'edit'))) {
trigger.hidden = true;
trigger.dataset.permissionHidden = 'true';
}
});
document.querySelectorAll('.page-body .dropdown-menu').forEach(menu => {
const available = [...menu.querySelectorAll('a,button,form')].some(item => !item.hidden && item
.dataset.permissionHidden !== 'true' && !item.closest('[data-permission-hidden="true"]'));
if (!available) {
const dropdown = menu.closest('.dropdown');
if (dropdown) dropdown.hidden = true;
}
});
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
document.querySelectorAll('form[method="post" i]').forEach(form => {
if (!csrfToken || form.querySelector('input[name="csrf"]')) return;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf';
input.value = csrfToken;
form.appendChild(input);
});
const validationMessages = {
required: < ? = json_encode(t('validation.required'), JSON_UNESCAPED_UNICODE) ? > ,
passwordMin: count => < ? = json_encode(t('validation.password_min'), JSON_UNESCAPED_UNICODE) ? >
.replace(':count', count),
invalidFormat: < ? = json_encode(t('validation.invalid_format'), JSON_UNESCAPED_UNICODE) ? > ,
checkField: < ? = json_encode(t('validation.check_field'), JSON_UNESCAPED_UNICODE) ? >
};
document.querySelectorAll('form').forEach(form => {
if (!form.querySelector('input[type="password"]')) return;
form.noValidate = true;
form.addEventListener('submit', event => {
form.querySelectorAll('.is-invalid').forEach(field => field.classList.remove(
'is-invalid'));
if (form.checkValidity()) return;
event.preventDefault();
event.stopPropagation();
form.querySelectorAll(':invalid').forEach(field => {
field.classList.add('is-invalid');
let feedback = field.parentElement.querySelector('.invalid-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
field.insertAdjacentElement('afterend', feedback);
}
feedback.textContent = field.validity.valueMissing ? validationMessages
.required : field.validity.tooShort ? validationMessages.passwordMin(
field.minLength) : field.validity.typeMismatch ? validationMessages
.invalidFormat : validationMessages.checkField;
});
form.querySelector(':invalid')?.focus();
});
form.querySelectorAll('input').forEach(field => field.addEventListener('input', () => field
.classList.remove('is-invalid')));
});
document.querySelectorAll('input[type="date"]').forEach(input => {
flatpickr(input, {
locale: < ? = json_encode(I18n::locale() === 'fr' ? 'fr' : 'default') ? > ,
altInput: true,
altFormat: 'd/m/Y',
dateFormat: 'Y-m-d',
allowInput: true,
disableMobile: true
});
});
</script>
<script src="/assets/accounting.js"></script>
<script>
document.querySelectorAll('select option').forEach(o => {
const s = o.textContent || '';
if (s) o.textContent = s.charAt(0).toLocaleUpperCase(document.documentElement.lang || 'fr') + s
.slice(1);
});
</script>
</body>
</html>