feat: optimize project-map Livewire component with eager loading, XSS prevention, URL validation, and performance improvements
This commit is contained in:
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
// Utility function to validate URL
|
||||||
attribution: '© <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: '© <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));">📼</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));">📼</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>
|
|
||||||
Reference in New Issue
Block a user