Publier Globinours 1.0.0-rc.3
This commit is contained in:
parent
ea8c24d622
commit
9a2b4068da
325 changed files with 38230 additions and 20 deletions
382
app/Views/layout.php
Normal file
382
app/Views/layout.php
Normal 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: '© <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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
||||
.replaceAll('"', '"').replaceAll("'", "'");
|
||||
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 d’oiseau” : 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue