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,18 +293,48 @@
</div> </div>
</div> </div>
</div> </div>
@push('scripts')
@push('styles')
<style> <style>
.leaflet-container { z-index: 0 !important; } .leaflet-container { z-index: 0 !important; }
</style> </style>
@endpush
@push('scripts')
<script> <script>
let map; let map;
const layers = {}; const layers = {};
let imageMarkersLayer = null; let imageMarkersLayer = null;
let imageViewerModal = null; let imageViewerModal = null;
let mapInitialized = false;
let combinedBounds = null;
// Utility function to escape HTML
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Utility function to validate URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
function initMap() { function initMap() {
if (map) return; // Prevent multiple initializations
if (mapInitialized || map) return;
mapInitialized = true;
const center = [{{ $project->lat }}, {{ $project->lng }}]; const center = [{{ $project->lat }}, {{ $project->lng }}];
map = L.map('map').setView(center, 16); map = L.map('map').setView(center, 16);
@@ -342,12 +372,16 @@
onEachFeature: function(feature, layer) { onEachFeature: function(feature, layer) {
const props = feature.properties || {}; const props = feature.properties || {};
const featId = props._feature_id || feature.id; const featId = props._feature_id || feature.id;
let content = `<b>${props.name || 'Elemento'}</b><br> // Escape all user-generated content for HTML context
{{ __("Progress") }}: ${props.progress || 0}%<br> const safeName = escapeHtml(props.name || 'Elemento');
{{ __("Responsible") }}: ${props.responsible || '-'}<br> 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>`; <button class="btn btn-xs btn-primary mt-1" onclick="selectFeature('${featId}')">✏️ Editar</button>`;
layer.bindPopup(content); layer.bindPopup(content);
layer.on('click', function() { selectFeature(' + featId + '); }); layer.on('click', function() { selectFeature(featId); });
} }
}); });
layers[{{ $phase->id }}] = phaseLayer; layers[{{ $phase->id }}] = phaseLayer;
@@ -355,28 +389,46 @@
phaseLayer.addTo(map); phaseLayer.addTo(map);
@endif @endif
} }
})(); })()
@endforeach @endforeach
// Initialize combined bounds
updateCombinedBounds();
setTimeout(() => { setTimeout(() => {
map.invalidateSize(); map.invalidateSize();
zoomToAllFeatures(); zoomToAllFeatures();
}, 200); }, 100); // Reduced from 200ms to 100ms
} }
function zoomToAllFeatures() { function updateCombinedBounds() {
if (!map) return; if (!map) return;
const bounds = L.latLngBounds(); combinedBounds = L.latLngBounds();
let hasBounds = false; let hasBounds = false;
for (let id in layers) { for (let id in layers) {
const layer = layers[id]; const layer = layers[id];
if (map.hasLayer(layer) && typeof layer.getBounds === 'function') { if (map.hasLayer(layer) && typeof layer.getBounds === 'function') {
const b = layer.getBounds(); const b = layer.getBounds();
if (b.isValid()) { bounds.extend(b); hasBounds = true; } if (b.isValid()) {
combinedBounds.extend(b);
hasBounds = true;
} }
} }
if (hasBounds) map.fitBounds(bounds, { padding: [20, 20] }); }
else map.setView([{{ $project->lat }}, {{ $project->lng }}], 16); 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) { function selectFeature(featureId) {
@@ -396,7 +448,7 @@
} }
document.addEventListener('livewire:init', function () { document.addEventListener('livewire:init', function () {
setTimeout(initMap, 100); setTimeout(initMap, 50); // Reduced from 100ms to 50ms
Livewire.on('layersUpdated', (activeIds) => { Livewire.on('layersUpdated', (activeIds) => {
// Livewire wraps single parameters in an array, so we need to extract the actual data // Livewire wraps single parameters in an array, so we need to extract the actual data
@@ -404,22 +456,45 @@
for (let id in layers) { for (let id in layers) {
const lid = parseInt(id); const lid = parseInt(id);
if (ids.includes(lid)) { if (ids.includes(lid)) {
if (!map.hasLayer(layers[id])) layers[id].addTo(map); if (!map.hasLayer(layers[id])) {
layers[id].addTo(map);
// Update combined bounds when adding a layer
updateCombinedBounds();
}
} else { } else {
if (map.hasLayer(layers[id])) map.removeLayer(layers[id]); if (map.hasLayer(layers[id])) {
map.removeLayer(layers[id]);
// Update combined bounds when removing a layer
updateCombinedBounds();
}
} }
} }
zoomToAllFeatures(); zoomToAllFeatures();
); });
Livewire.on('centerMap', zoomToAllFeatures); Livewire.on('centerMap', zoomToAllFeatures);
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); }); 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 // Toggle imágenes en mapa
Livewire.on('featureImagesToggled', (show, markers) => { Livewire.on('featureImagesToggled', (show, markers) => {
const m = Array.isArray(markers) ? markers : markers[1]; const m = Array.isArray(markers) ? markers : markers[1];
const s = Array.isArray(show) ? show[0] : show; const s = Array.isArray(show) ? show[0] : show;
if (imageMarkersLayer) { map.removeLayer(imageMarkersLayer); imageMarkersLayer = null; } if (imageMarkersLayer) {
map.removeLayer(imageMarkersLayer);
imageMarkersLayer = null;
// Update bounds when removing image markers layer
updateCombinedBounds();
}
if (s && m && m.length > 0) { if (s && m && m.length > 0) {
imageMarkersLayer = L.layerGroup().addTo(map); imageMarkersLayer = L.layerGroup().addTo(map);
const photoIcon = L.divIcon({ const photoIcon = L.divIcon({
@@ -429,26 +504,41 @@
iconAnchor: [10, 10] iconAnchor: [10, 10]
}); });
m.forEach(marker => { m.forEach(marker => {
const popupContent = `<b>${marker.name}</b><br> // Validate URL and sanitize name for security
<img src="${marker.image_url}" class="max-w-[200px] max-h-[150px] rounded cursor-pointer" const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
onclick="window.openViewer('${marker.image_url}', '${marker.image_name}')" />`; 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 }) L.marker([marker.lat, marker.lng], { icon: photoIcon })
.bindPopup(popupContent) .bindPopup(popupContent)
.addTo(imageMarkersLayer); .addTo(imageMarkersLayer);
}
}); });
// Update bounds when adding image markers layer
updateCombinedBounds();
} }
}); });
// Modal para ver imagen al hacer clic // Modal para ver imagen al hacer clic
window.openViewer = function(url, name) { window.openViewer = function(url, name) {
// Validate URL and sanitize name for security
if (!isValidUrl(url)) {
console.error('Invalid URL provided to openViewer:', url);
return;
}
const safeName = escapeHtml(name);
if (imageViewerModal) imageViewerModal.remove(); if (imageViewerModal) imageViewerModal.remove();
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.id = 'imageViewerModal'; 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.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"> 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> <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)" /> <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)" />
<p style="color:white;text-align:center;margin-top:8px;font-size:14px">${name}</p> <p style="color:white;text-align:center;margin-top:8px;font-size:14px">${safeName}</p>
</div>`; </div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay); document.body.appendChild(overlay);
@@ -456,3 +546,4 @@
}; };
}); });
</script> </script>
@endpush