Globinours/app/Views/layout.php

382 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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>