feat: optimize project-map Livewire component with eager loading, XSS prevention, URL validation, and performance improvements

This commit is contained in:
2026-05-28 21:46:25 +02:00
parent 2711dcf2f2
commit c832d4f3da
@@ -17,7 +17,7 @@
class="toggle toggle-xs toggle-primary"> class="toggle toggle-xs toggle-primary">
<span style="color: {{ $phase->color }};" class="text-lg"></span> <span style="color: {{ $phase->color }};" class="text-lg"></span>
<span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span> <span class="flex-1 font-medium text-sm truncate">{{ $phase->name }}</span>
<span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}%</span> <span class="badge badge-sm {{ $phase->progress_percent >= 100 ? 'badge-success' : 'badge-ghost' }}">{{ $phase->progress_percent }}</span>
</div> </div>
{{-- Capas de esta fase --}} {{-- Capas de esta fase --}}
@@ -61,7 +61,7 @@
<div class="mt-2 space-y-1"> <div class="mt-2 space-y-1">
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full"> <a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
📁 {{ __("Project files") }} 📁 {{ __("Project files") }}
</a> </button>
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full"> <button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
📍 {{ __("Centered in project") }} 📍 {{ __("Centered in project") }}
</button> </button>
@@ -189,7 +189,7 @@
@foreach($inspectionHistory as $ins) @foreach($inspectionHistory as $ins)
<div class="border rounded p-2 text-xs"> <div class="border rounded p-2 text-xs">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="font-medium">{{ $ins->template?->name ?? {{ __("Inspection") }} }}</span> <span class="font-medium">{{ $ins->template?->name ?? __("Inspection") }}</span>
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span> <span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
</div> </div>
@if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif @if($ins->user)<span class="text-gray-500">por {{ $ins->user->name }}</span>@endif
@@ -293,166 +293,257 @@
</div> </div>
</div> </div>
</div> </div>
@push('styles')
<style>
.leaflet-container { z-index: 0 !important; }
</style>
@endpush
@push('scripts') @push('scripts')
<style> <script>
.leaflet-container { z-index: 0 !important; } let map;
</style> const layers = {};
<script> let imageMarkersLayer = null;
let map; let imageViewerModal = null;
const layers = {}; let mapInitialized = false;
let imageMarkersLayer = null; let combinedBounds = null;
let imageViewerModal = null;
function initMap() { // Utility function to escape HTML
if (map) return; function escapeHtml(text) {
const center = [{{ $project->lat }}, {{ $project->lng }}]; if (text === null || text === undefined) return '';
map = L.map('map').setView(center, 16); return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { // Utility function to validate URL
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB' function isValidUrl(string) {
}).addTo(map); try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
// Cargar fases y sus features function initMap() {
@foreach($phases as $phase) // Prevent multiple initializations
@php if (mapInitialized || map) return;
$phaseFeatures = $phase->features()->with('layer.phase')->get(); mapInitialized = true;
$fc = [
'type' => 'FeatureCollection', const center = [{{ $project->lat }}, {{ $project->lng }}];
'features' => $phaseFeatures->map(function($f) { map = L.map('map').setView(center, 16);
return [
'type' => 'Feature', L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
'id' => $f->id, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
'geometry' => $f->geometry, }).addTo(map);
'properties' => array_merge($f->properties ?? [], [
'name' => $f->name, // Cargar fases y sus features
'progress' => $f->progress, @foreach($phases as $phase)
'responsible' => $f->responsible, @php
'template_id' => $f->template_id, $phaseFeatures = $phase->features()->with('layer.phase')->get();
'_feature_id' => $f->id, $fc = [
]) 'type' => 'FeatureCollection',
]; 'features' => $phaseFeatures->map(function($f) {
})->values()->toArray() return [
]; 'type' => 'Feature',
@endphp 'id' => $f->id,
(function() { 'geometry' => $f->geometry,
const data = @json($fc); 'properties' => array_merge($f->properties ?? [], [
if (data && data.features && data.features.length > 0) { 'name' => $f->name,
const phaseLayer = L.geoJSON(data, { 'progress' => $f->progress,
style: { color: '{{ $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 }, 'responsible' => $f->responsible,
onEachFeature: function(feature, layer) { 'template_id' => $f->template_id,
const props = feature.properties || {}; '_feature_id' => $f->id,
const featId = props._feature_id || feature.id; ])
let content = `<b>${props.name || 'Elemento'}</b><br> ];
{{ __("Progress") }}: ${props.progress || 0}%<br> })->values()->toArray()
{{ __("Responsible") }}: ${props.responsible || '-'}<br> ];
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`; @endphp
layer.bindPopup(content); (function() {
layer.on('click', function() { selectFeature(' + featId + '); }); const data = @json($fc);
if (data && data.features && data.features.length > 0) {
const phaseLayer = L.geoJSON(data, {
style: { color: '{{ $phase->color }}', weight: 3, opacity: 0.8, fillOpacity: 0.3 },
onEachFeature: function(feature, layer) {
const props = feature.properties || {};
const featId = props._feature_id || feature.id;
// Escape all user-generated content for HTML context
const safeName = escapeHtml(props.name || 'Elemento');
const safeProgress = escapeHtml(props.progress || 0);
const safeResponsible = escapeHtml(props.responsible || '-');
let content = `<b>${safeName}</b><br>
{{ __("Progress") }}: ${safeProgress}%<br>
{{ __("Responsible") }}: ${safeResponsible}<br>
<button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`;
layer.bindPopup(content);
layer.on('click', function() { selectFeature(featId); });
}
});
layers[{{ $phase->id }}] = phaseLayer;
@if(in_array($phase->id, $activeLayers))
phaseLayer.addTo(map);
@endif
}
})()
@endforeach
// Initialize combined bounds
updateCombinedBounds();
setTimeout(() => {
map.invalidateSize();
zoomToAllFeatures();
}, 100); // Reduced from 200ms to 100ms
}
function updateCombinedBounds() {
if (!map) return;
combinedBounds = L.latLngBounds();
let hasBounds = false;
for (let id in layers) {
const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds();
if (b.isValid()) {
combinedBounds.extend(b);
hasBounds = true;
}
}
}
return hasBounds;
}
function zoomToAllFeatures() {
if (!map) return;
// Update combined bounds if needed
updateCombinedBounds();
if (combinedBounds && combinedBounds.isValid()) {
map.fitBounds(combinedBounds, { padding: [20, 20] });
} else {
map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
}
function selectFeature(featureId) {
@this.selectFeature(featureId);
}
function getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
map.setView(latlng, 16);
}, () => alert('No se pudo obtener la ubicación'));
} else {
alert('Geolocalización no soportada');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 50); // Reduced from 100ms to 50ms
Livewire.on('layersUpdated', (activeIds) => {
// Livewire wraps single parameters in an array, so we need to extract the actual data
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) {
layers[id].addTo(map);
// Update combined bounds when adding a layer
updateCombinedBounds();
}
} else {
if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]);
// Update combined bounds when removing a layer
updateCombinedBounds();
}
}
}
zoomToAllFeatures();
});
Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => {
if (map) {
// Throttle resize events to prevent excessive calls
if (!this.resizeTimeout) {
this.resizeTimeout = setTimeout(() => {
map.invalidateSize();
this.resizeTimeout = null;
}, 100);
}
}
});
// Toggle imágenes en mapa
Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null;
// Update bounds when removing image markers layer
updateCombinedBounds();
}
if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">&#128252;</span>',
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
// Validate URL and sanitize name for security
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
const safeName = escapeHtml(marker.image_name || '');
if (safeUrl) { // Only add marker if URL is valid
const popupContent = `<b>${safeName}</b><br>
<img src="${safeUrl}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${safeUrl}', '${safeName}')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
} }
}); });
layers[{{ $phase->id }}] = phaseLayer; // Update bounds when adding image markers layer
@if(in_array($phase->id, $activeLayers)) updateCombinedBounds();
phaseLayer.addTo(map);
@endif
} }
})(); });
@endforeach
setTimeout(() => { // Modal para ver imagen al hacer clic
map.invalidateSize(); window.openViewer = function(url, name) {
zoomToAllFeatures(); // Validate URL and sanitize name for security
}, 200); if (!isValidUrl(url)) {
} console.error('Invalid URL provided to openViewer:', url);
return;
function zoomToAllFeatures() {
if (!map) return;
const bounds = L.latLngBounds();
let hasBounds = false;
for (let id in layers) {
const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds();
if (b.isValid()) { bounds.extend(b); hasBounds = true; }
}
}
if (hasBounds) map.fitBounds(bounds, { padding: [20, 20] });
else map.setView([{{ $project->lat }}, {{ $project->lng }}], 16);
}
function selectFeature(featureId) {
@this.selectFeature(featureId);
}
function getUserLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition((pos) => {
const latlng = [pos.coords.latitude, pos.coords.longitude];
L.marker(latlng).addTo(map).bindPopup('Tu ubicación').openPopup();
map.setView(latlng, 16);
}, () => alert('No se pudo obtener la ubicación'));
} else {
alert('Geolocalización no soportada');
}
}
document.addEventListener('livewire:init', function () {
setTimeout(initMap, 100);
Livewire.on('layersUpdated', (activeIds) => {
// Livewire wraps single parameters in an array, so we need to extract the actual data
const ids = Array.isArray(activeIds) ? activeIds[0] : activeIds;
for (let id in layers) {
const lid = parseInt(id);
if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) layers[id].addTo(map);
} else {
if (map.hasLayer(layers[id])) map.removeLayer(layers[id]);
} }
}
zoomToAllFeatures(); const safeName = escapeHtml(name);
);
if (imageViewerModal) imageViewerModal.remove();
Livewire.on('centerMap', zoomToAllFeatures); const overlay = document.createElement('div');
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); }); overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
// Toggle imágenes en mapa overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
Livewire.on('featureImagesToggled', (show, markers) => { <button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer"></button>
const m = Array.isArray(markers) ? markers : markers[1]; <img src="${url}" alt="${safeName}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
const s = Array.isArray(show) ? show[0] : show; <p style="color:white;text-align:center;margin-top:8px;font-size:14px">${safeName}</p>
if (imageMarkersLayer) { map.removeLayer(imageMarkersLayer); imageMarkersLayer = null; } </div>`;
if (s && m && m.length > 0) { overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
imageMarkersLayer = L.layerGroup().addTo(map); document.body.appendChild(overlay);
const photoIcon = L.divIcon({ imageViewerModal = overlay;
html: '<span style="font-size: 20px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.5));">&#128252;</span>', };
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
m.forEach(marker => {
const popupContent = `<b>${marker.name}</b><br>
<img src="${marker.image_url}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer"
onclick="window.openViewer('${marker.image_url}', '${marker.image_name}')" />`;
L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent)
.addTo(imageMarkersLayer);
});
}
}); });
</script>
// Modal para ver imagen al hacer clic @endpush
window.openViewer = function(url, name) {
if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div');
overlay.id = 'imageViewerModal';
overlay.style.cssText = 'position:fixed;inset:0;z-index:5000;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;padding:20px;cursor:pointer';
overlay.innerHTML = `<div style="position:relative;max-width:90vw;max-height:90vh">
<button onclick="this.closest('#imageViewerModal').remove()" style="position:absolute;top:-30px;right:0;color:white;font-size:24px;background:none;border:none;cursor:pointer"></button>
<img src="${url}" alt="${name}" style="max-width:100%;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,0.5)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${name}</p>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
imageViewerModal = overlay;
};
});
</script>