de68638d7c
1. Hide the project navigation bar (kept in DOM via 'hidden', not deleted) 2. Move the tabs into the panel header where the 'Map' title was 3. (tabs setActiveTab logic already correct — recompiled) 4. Make the phases/layers panel collapsible via an Alpine toggle button 5. Replace all emoji icons with blade-heroicons (<x-heroicon-o-*>) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
578 lines
33 KiB
PHP
578 lines
33 KiB
PHP
<div x-data="{ formFullscreen: $wire.entangle('formFullscreen') }"
|
|
class="flex flex-col lg:flex-row gap-0 h-screen p-1">
|
|
<!-- Columna izquierda: Mapa -->
|
|
<div x-show="!formFullscreen" x-cloak x-data="{ showLayers: true }" class="w-full lg:w-2/3 flex-1 relative">
|
|
<div id="map" style="height: 100%; min-height: 500px; width: 100%;" wire:ignore></div>
|
|
|
|
<!-- Toggle del panel de fases/capas -->
|
|
<button @click="showLayers = !showLayers"
|
|
class="absolute top-2 right-2 z-[1001] btn btn-sm btn-circle shadow-lg"
|
|
title="{{ __('Show/hide panel') }}">
|
|
<x-heroicon-o-x-mark class="w-5 h-5" x-show="showLayers" />
|
|
<x-heroicon-o-bars-3 class="w-5 h-5" x-show="!showLayers" x-cloak />
|
|
</button>
|
|
|
|
<!-- Panel lateral de capas -->
|
|
<div x-show="showLayers" x-transition
|
|
class="absolute top-14 right-2 z-[1000] bg-base-100 rounded-box shadow-xl p-4 w-72 border border-base-300 text-sm max-h-[calc(100vh-4rem)] overflow-y-auto">
|
|
<h3 class="font-semibold text-base mb-2">{{ __('Phases and layers') }}</h3>
|
|
<div class="space-y-3">
|
|
@foreach($phases as $phase)
|
|
<div class="border rounded-lg p-2 {{ in_array($phase->id, $activeLayers) ? 'bg-base-200' : '' }}">
|
|
<div class="flex items-center gap-2">
|
|
<input type="checkbox"
|
|
wire:change="toggleLayer({{ $phase->id }})"
|
|
@if(in_array($phase->id, $activeLayers)) checked @endif
|
|
class="toggle toggle-xs toggle-primary">
|
|
<span style="color: {{ $phase->color }};" class="text-lg">⬤</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>
|
|
</div>
|
|
|
|
{{-- Capas de esta fase --}}
|
|
@if($phase->layers->isNotEmpty())
|
|
<div class="ml-7 mt-1 space-y-1">
|
|
@foreach($phase->layers as $layer)
|
|
<div class="flex items-center gap-1 text-xs text-gray-600">
|
|
<span class="w-2 h-2 rounded-full inline-block" style="background: {{ $layer->color ?? '#ccc' }}"></span>
|
|
<span class="flex-1 truncate">{{ $layer->name }}</span>
|
|
<span class="badge badge-xs">{{ $layer->features_count ?? $layer->features->count() }} {{ __('elem.') }}</span>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
|
|
{{-- Botón para ir a gestión de capas de esta fase --}}
|
|
<div class="mt-1 ml-7">
|
|
<a href="{{ route('layers.manage', [$project, $phase]) }}" class="btn btn-xs btn-outline btn-primary gap-1">
|
|
<x-heroicon-o-pencil-square class="w-3.5 h-3.5" /> {{ __('Manage Layers') }}
|
|
</a>
|
|
<a href="{{ route('phases.progress', $phase) }}" class="btn btn-xs btn-outline gap-1">
|
|
<x-heroicon-o-chart-bar class="w-3.5 h-3.5" /> {{ __('Progress') }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- Checkbox imágenes en mapa --}}
|
|
<div class="mt-3">
|
|
<label class="flex items-center gap-2 text-xs cursor-pointer">
|
|
<input type="checkbox" wire:change="toggleFeatureImages" @if($showFeatureImages) checked @endif class="checkbox checkbox-xs checkbox-primary" />
|
|
<x-heroicon-o-photo class="w-4 h-4" /> {{ __('Show images on map') }}
|
|
@if($featureImageMarkers)
|
|
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
|
|
@endif
|
|
</label>
|
|
</div>
|
|
|
|
{{-- Botones generales --}}
|
|
<div class="mt-2 space-y-1">
|
|
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full gap-1">
|
|
<x-heroicon-o-folder class="w-4 h-4" /> {{ __('Project files') }}
|
|
</a>
|
|
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full gap-1">
|
|
<x-heroicon-o-map-pin class="w-4 h-4" /> {{ __('Centered in project') }}
|
|
</button>
|
|
<button onclick="getUserLocation()" class="btn btn-sm btn-outline w-full gap-1">
|
|
<x-heroicon-o-viewfinder-circle class="w-4 h-4" /> {{ __('My location') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Columna derecha: {{ __('Edit') }} de progreso / inspecciones -->
|
|
<div class="w-full lg:w-1/3 transition-all duration-300" :class="{'lg:w-full': formFullscreen}">
|
|
<div class="card bg-base-100 shadow-xl h-full flex flex-col">
|
|
<div class="card-body overflow-y-auto flex-1">
|
|
<div class="flex justify-between items-center gap-2 mb-4">
|
|
<!-- Tabs -->
|
|
<div class="tabs tabs-boxed flex-wrap">
|
|
<button wire:click="setActiveTab('edit')" class="tab {{ $activeTab === 'edit' ? 'tab-active' : '' }}">{{ __('Edit') }}</button>
|
|
<button wire:click="setActiveTab('features')" class="tab {{ $activeTab === 'features' ? 'tab-active' : '' }}">{{ __('Features') }}</button>
|
|
<button wire:click="setActiveTab('inspections')" class="tab {{ $activeTab === 'inspections' ? 'tab-active' : '' }}">{{ __('Inspections') }}</button>
|
|
<button wire:click="setActiveTab('issues')" class="tab {{ $activeTab === 'issues' ? 'tab-active' : '' }} gap-1">
|
|
{{ __('Issues') }}
|
|
@if($openIssuesCount > 0)
|
|
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
|
@endif
|
|
</button>
|
|
</div>
|
|
<button wire:click="toggleFullscreen" class="btn btn-circle btn-sm shrink-0" title="{{ __('Fullscreen') }}">
|
|
<span x-text="formFullscreen ? '✕' : '⤢'"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Project navigation bar (hidden for now, kept for later) -->
|
|
<div class="hidden flex-wrap gap-1 mb-3">
|
|
<a href="{{ route('projects.dashboard', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.dashboard') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-squares-2x2 class="w-3.5 h-3.5" /> {{ __('Dashboard') }}
|
|
</a>
|
|
<a href="{{ route('projects.map', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.map') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-map class="w-3.5 h-3.5" /> {{ __('Map') }}
|
|
</a>
|
|
<a href="{{ route('projects.gantt', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.gantt') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-calendar-days class="w-3.5 h-3.5" /> {{ __('Gantt') }}
|
|
</a>
|
|
<a href="{{ route('projects.report', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.report') ? 'btn-primary' : 'btn-outline' }}">
|
|
<x-heroicon-o-document-text class="w-3.5 h-3.5" /> {{ __('Report') }}
|
|
</a>
|
|
<a href="{{ route('projects.issues', $project) }}"
|
|
class="btn btn-xs gap-1 {{ request()->routeIs('projects.issues') ? 'btn-error' : 'btn-outline' }}">
|
|
<x-heroicon-o-exclamation-triangle class="w-3.5 h-3.5" /> {{ __('Issues') }}
|
|
@if($openIssuesCount > 0)
|
|
<span class="badge badge-error badge-xs">{{ $openIssuesCount }}</span>
|
|
@endif
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Tab Content -->
|
|
<div class="tab-content">
|
|
@if($activeTab === 'edit')
|
|
@if($selectedFeature)
|
|
<!-- Feature seleccionado -->
|
|
<div class="border rounded-lg p-3 mb-3 bg-base-200">
|
|
<h3 class="font-bold text-sm">{{ $selectedFeature->name ?? __('Feature') }}</h3>
|
|
<p class="text-xs text-gray-500">{{ __('Phase') }}: {{ $selectedFeature->layer?->phase?->name ?? '—' }}</p>
|
|
<p class="text-xs text-gray-500">{{ __('Layer') }}: {{ $selectedFeature->layer?->name ?? '—' }}</p>
|
|
</div>
|
|
|
|
{{-- Progreso --}}
|
|
<div class="form-control mb-3">
|
|
<label class="label-text">{{ __('Progress') }}: {{ $editProgress }}%</label>
|
|
<input type="range" min="0" max="100" wire:model.live="editProgress" class="range range-primary range-sm" />
|
|
<div class="flex justify-between text-xs">
|
|
<span>0%</span><span>50%</span><span>100%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-control mb-3">
|
|
<label class="label-text">{{ __('Responsible') }}</label>
|
|
<input type="text" wire:model="editResponsible" class="input input-bordered input-sm" placeholder="{{ __('Name of responsible') }}" />
|
|
</div>
|
|
|
|
<button wire:click="saveFeatureProgress" class="btn btn-primary btn-sm w-full mb-3 gap-1">
|
|
<x-heroicon-o-check-circle class="w-4 h-4" /> {{ __('Save progress') }}
|
|
</button>
|
|
|
|
{{-- Gestor de archivos del feature --}}
|
|
<details class="mb-3 border rounded-lg">
|
|
<summary class="text-xs font-semibold cursor-pointer p-2 bg-base-200 rounded-t-lg">
|
|
<x-heroicon-o-paper-clip class="w-4 h-4 inline" /> {{ __('Files of element') }}
|
|
</summary>
|
|
<div class="p-2">
|
|
@livewire('media-manager', [
|
|
'mediableType' => 'App\\Models\\Feature',
|
|
'mediableId' => $selectedFeature->id,
|
|
], key('media-feature-' . $selectedFeature->id))
|
|
</div>
|
|
</details>
|
|
|
|
{{-- Templates / Inspecciones --}}
|
|
@if($templates->isNotEmpty())
|
|
<div class="divider text-xs">{{ __('Inspection') }}</div>
|
|
<div class="form-control mb-2">
|
|
<label class="label-text">{{ __('Template') }}</label>
|
|
<select wire:model.live="selectedTemplateId" wire:change="onTemplateChange" class="select select-bordered select-sm">
|
|
<option value="">{{ __('Select template...') }}</option>
|
|
@foreach($templates as $t)
|
|
<option value="{{ $t->id }}">{{ $t->name }}</option>
|
|
@endforeach
|
|
</select>
|
|
</div>
|
|
|
|
@if($selectedTemplateId && !empty($inspectionFormData))
|
|
@php $template = $templates->firstWhere('id', $selectedTemplateId); @endphp
|
|
@if($template)
|
|
@foreach($template->fields as $field)
|
|
<div class="mb-2">
|
|
<label class="label-text text-xs">{{ $field['label'] }} @if($field['required'] ?? false)<span class="text-error">*</span>@endif</label>
|
|
@switch($field['type'] ?? 'text')
|
|
@case('percentage')
|
|
<div class="flex items-center gap-1">
|
|
<input type="number" wire:model="inspectionFormData.{{ $field['name'] }}" min="0" max="100" class="input input-bordered input-sm w-16" />
|
|
<span class="text-xs">%</span>
|
|
<input type="range" min="0" max="100" wire:model.live="inspectionFormData.{{ $field['name'] }}" class="range range-primary range-xs flex-1" />
|
|
</div>
|
|
@break
|
|
@case('boolean')
|
|
<input type="checkbox" wire:model="inspectionFormData.{{ $field['name'] }}" class="checkbox checkbox-sm" />
|
|
@break
|
|
@case('select')
|
|
<select wire:model="inspectionFormData.{{ $field['name'] }}" class="select select-bordered select-sm w-full">
|
|
<option value="">{{ __('Select') }}</option>
|
|
@foreach(explode(',', $field['options'] ?? '') as $opt)
|
|
<option value="{{ trim($opt) }}">{{ trim($opt) }}</option>
|
|
@endforeach
|
|
</select>
|
|
@break
|
|
@case('textarea')
|
|
<textarea wire:model="inspectionFormData.{{ $field['name'] }}" rows="2" class="textarea textarea-bordered textarea-sm w-full"></textarea>
|
|
@break
|
|
@default
|
|
<input type="{{ $field['type'] ?? 'text' }}" wire:model="inspectionFormData.{{ $field['name'] }}" class="input input-bordered input-sm w-full" />
|
|
@endswitch
|
|
</div>
|
|
@endforeach
|
|
<button wire:click="saveInspection" class="btn btn-primary btn-xs w-full mt-1">{{ __('Register inspection') }}</button>
|
|
@endif
|
|
@endif
|
|
|
|
{{-- Historial de inspecciones --}}
|
|
@if($inspectionHistory->isNotEmpty())
|
|
<div class="divider text-xs">{{ __('History') }}</div>
|
|
<div class="space-y-1 max-h-40 overflow-y-auto">
|
|
@foreach($inspectionHistory as $ins)
|
|
<div class="border rounded p-2 text-xs" wire:key="hist-{{ $ins->id }}">
|
|
<div class="flex justify-between">
|
|
<span class="font-medium">{{ $ins->template?->name ?? __('Inspection') }}</span>
|
|
<span class="text-gray-400">{{ $ins->created_at->diffForHumans() }}</span>
|
|
</div>
|
|
@if($ins->user)<span class="text-gray-500">{{ __('by') }} {{ $ins->user->name }}</span>@endif
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
@endif
|
|
@else
|
|
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<div>
|
|
<h3 class="font-bold">{{ __('No templates yet') }}</h3>
|
|
<div class="text-xs">{{ __('Create an inspection template') }}.</div>
|
|
</div>
|
|
<a href="{{ route('projects.templates', $project) }}" class="btn btn-primary btn-sm">{{ __('Create') }}</a>
|
|
</div>
|
|
@endif
|
|
@else
|
|
<div class="text-center text-gray-400 py-8">
|
|
<x-heroicon-o-cursor-arrow-rays class="w-10 h-10 mx-auto opacity-30" />
|
|
<p>{{ __('Click on a map element or search above to edit it') }}</p>
|
|
</div>
|
|
@endif
|
|
@elseif($activeTab === 'features')
|
|
<!-- Features Table -->
|
|
@if($allFeatures->isNotEmpty())
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm table-compact">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ __('Feature') }}</th>
|
|
<th>{{ __('Layer') }}</th>
|
|
<th>{{ __('Phase') }}</th>
|
|
<th>{{ __('Progress') }}</th>
|
|
<th>{{ __('Responsible') }}</th>
|
|
<th>{{ __('Template') }}</th>
|
|
<th class="w-16"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($allFeatures as $feature)
|
|
<tr class="hover" wire:click="selectFeature({{ $feature->id }})">
|
|
<td>{{ $feature->name }}</td>
|
|
<td>{{ $feature->layer->name }}</td>
|
|
<td>{{ $feature->layer->phase->name }}</td>
|
|
<td>{{ $feature->progress }}%</td>
|
|
<td>{{ $feature->responsible ?? '-' }}</td>
|
|
<td>{{ $feature->template?->name ?? '-' }}</td>
|
|
<td class="justify-end">
|
|
<button class="btn btn-xs btn-outline btn-primary"><x-heroicon-o-pencil class="w-3.5 h-3.5" /></button>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@else
|
|
<div class="text-center text-gray-400 py-8">
|
|
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
|
|
<p>{{ __('No elements in this project') }}</p>
|
|
</div>
|
|
@endif
|
|
@elseif($activeTab === 'inspections')
|
|
<!-- Inspections Table -->
|
|
@if($allInspections->isNotEmpty())
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm table-compact">
|
|
<thead>
|
|
<tr>
|
|
<th>{{ __('Date') }}</th>
|
|
<th>{{ __('Feature') }}</th>
|
|
<th>{{ __('Template') }}</th>
|
|
<th>{{ __('User') }}</th>
|
|
<th class="w-16"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach($allInspections as $inspection)
|
|
<tr>
|
|
<td>{{ $inspection->created_at->format('d/m/Y') }}</td>
|
|
<td>{{ $inspection->feature->name }}</td>
|
|
<td>{{ $inspection->template->name }}</td>
|
|
<td>{{ $inspection->user->name }}</td>
|
|
<td class="justify-end">
|
|
<button class="btn btn-xs btn-outline btn-info"><x-heroicon-o-eye class="w-3.5 h-3.5" /></button>
|
|
</td>
|
|
</tr>
|
|
@endforeach
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
@else
|
|
<div class="text-center text-gray-400 py-8">
|
|
<x-heroicon-o-clipboard-document-list class="w-10 h-10 mx-auto opacity-30" />
|
|
<p>{{ __('No inspections registered') }}</p>
|
|
</div>
|
|
@endif
|
|
@elseif($activeTab === 'issues')
|
|
<!-- Issues tab: render embedded IssueManager component -->
|
|
@livewire('issue-manager', ['project' => $project], key('issues-tab-' . $project->id))
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@push('styles')
|
|
<style>
|
|
.leaflet-container { z-index: 0 !important; }
|
|
</style>
|
|
@endpush
|
|
|
|
@push('scripts')
|
|
<script>
|
|
let map;
|
|
const layers = {};
|
|
let imageMarkersLayer = 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Utility function to validate URL
|
|
function isValidUrl(string) {
|
|
try {
|
|
new URL(string);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function initMap() {
|
|
// Prevent multiple initializations
|
|
if (mapInitialized || map) return;
|
|
mapInitialized = true;
|
|
|
|
const center = [{{ $project->lat }}, {{ $project->lng }}];
|
|
map = L.map('map').setView(center, 16);
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> & CartoDB'
|
|
}).addTo(map);
|
|
|
|
// Cargar fases y sus features
|
|
@foreach($phases as $phase)
|
|
@php
|
|
$phaseFeatures = $phase->features()->with('layer.phase')->get();
|
|
$fc = [
|
|
'type' => 'FeatureCollection',
|
|
'features' => $phaseFeatures->map(function($f) {
|
|
return [
|
|
'type' => 'Feature',
|
|
'id' => $f->id,
|
|
'geometry' => $f->geometry,
|
|
'properties' => array_merge($f->properties ?? [], [
|
|
'name' => $f->name,
|
|
'progress' => $f->progress,
|
|
'responsible' => $f->responsible,
|
|
'template_id' => $f->template_id,
|
|
'_feature_id' => $f->id,
|
|
])
|
|
];
|
|
})->values()->toArray()
|
|
];
|
|
@endphp
|
|
(function() {
|
|
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;
|
|
const safeName = escapeHtml(props.name || '{{ __('Feature') }}');
|
|
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}')">✏️ {{ __('Edit') }}</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);
|
|
}
|
|
|
|
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;
|
|
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('{{ __('My location') }}').openPopup();
|
|
map.setView(latlng, 16);
|
|
}, () => alert('{{ __('No results') }}'));
|
|
} else {
|
|
alert('{{ __('No results') }}');
|
|
}
|
|
}
|
|
|
|
document.addEventListener('livewire:init', function () {
|
|
setTimeout(initMap, 50);
|
|
|
|
Livewire.on('layersUpdated', (activeIds) => {
|
|
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);
|
|
updateCombinedBounds();
|
|
}
|
|
} else {
|
|
if (map.hasLayer(layers[id])) {
|
|
map.removeLayer(layers[id]);
|
|
updateCombinedBounds();
|
|
}
|
|
}
|
|
}
|
|
zoomToAllFeatures();
|
|
});
|
|
|
|
Livewire.on('centerMap', zoomToAllFeatures);
|
|
Livewire.on('mapResize', () => {
|
|
if (map) {
|
|
if (!this.resizeTimeout) {
|
|
this.resizeTimeout = setTimeout(() => {
|
|
map.invalidateSize();
|
|
this.resizeTimeout = null;
|
|
}, 100);
|
|
}
|
|
}
|
|
});
|
|
|
|
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;
|
|
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 => {
|
|
const safeUrl = isValidUrl(marker.image_url) ? marker.image_url : '';
|
|
const safeName = escapeHtml(marker.image_name || '');
|
|
if (safeUrl) {
|
|
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);
|
|
}
|
|
});
|
|
updateCombinedBounds();
|
|
}
|
|
});
|
|
|
|
window.openViewer = function(url, name) {
|
|
if (!isValidUrl(url)) {
|
|
console.error('Invalid URL provided to openViewer:', url);
|
|
return;
|
|
}
|
|
const safeName = escapeHtml(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="${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">${safeName}</p>
|
|
</div>`;
|
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
|
document.body.appendChild(overlay);
|
|
imageViewerModal = overlay;
|
|
};
|
|
});
|
|
</script>
|
|
@endpush
|