Sistema de archivos multimedia: MediaManager, checkbox imágenes en mapa, modal visor, subida por feature/proyecto
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<div>
|
||||
@if(session()->has('message'))
|
||||
<div class="alert alert-success mb-2">{{ session('message') }}</div>
|
||||
@endif
|
||||
@if(session()->has('error'))
|
||||
<div class="alert alert-error mb-2">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
{{-- Subida --}}
|
||||
<div class="card bg-base-100 shadow-xl mb-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-sm">Subir archivos</h3>
|
||||
|
||||
<form wire:submit.prevent="upload" class="space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label-text">Archivos (hasta 100MB c/u)</label>
|
||||
<input type="file" wire:model="uploadFiles" multiple class="file-input file-input-bordered file-input-sm" />
|
||||
@error('uploadFiles.*') <span class="text-error text-xs">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="form-control">
|
||||
<label class="label-text">Categoría</label>
|
||||
<select wire:model="uploadCategory" class="select select-bordered select-sm">
|
||||
<option value="image">Imagen</option>
|
||||
<option value="document">Documento</option>
|
||||
<option value="other">Otro</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label-text">Descripción</label>
|
||||
<input type="text" wire:model="uploadDescription" class="input input-bordered input-sm" placeholder="Opcional" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-sm w-full">Subir archivos</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Galería de imágenes --}}
|
||||
@if($images->isNotEmpty())
|
||||
<div class="card bg-base-100 shadow-xl mb-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-sm">Imágenes ({{ $images->count() }})</h3>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
@foreach($images as $media)
|
||||
<div class="relative group cursor-pointer" wire:click="viewMedia({{ $media->id }})">
|
||||
<img src="{{ $media->url }}" alt="{{ $media->name }}"
|
||||
class="w-full h-20 object-cover rounded border hover:opacity-80 transition-opacity" />
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-[10px] px-1 truncate rounded-b">
|
||||
{{ $media->name }}
|
||||
</div>
|
||||
<button wire:click.stop="deleteMedia({{ $media->id }})"
|
||||
class="absolute top-0 right-0 bg-error text-white text-xs w-4 h-4 rounded-full opacity-0 group-hover:opacity-100 transition-opacity leading-none"
|
||||
onclick="return confirm('¿Borrar {{ $media->name }}?')">×</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Documentos --}}
|
||||
@if($documents->isNotEmpty())
|
||||
<div class="card bg-base-100 shadow-xl mb-4">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-sm">Documentos ({{ $documents->count() }})</h3>
|
||||
<div class="space-y-1">
|
||||
@foreach($documents as $media)
|
||||
<div class="flex items-center gap-2 p-2 border rounded text-sm hover:bg-base-200">
|
||||
<span class="text-lg">
|
||||
@switch($media->file_extension)
|
||||
@case('pdf') 📄 @break
|
||||
@case('doc') @case('docx') 📝 @break
|
||||
@case('xls') @case('xlsx') 📊 @break
|
||||
@default 📎
|
||||
@endswitch
|
||||
</span>
|
||||
<a href="{{ $media->url }}" target="_blank" class="flex-1 truncate link link-primary">
|
||||
{{ $media->name }}
|
||||
</a>
|
||||
<span class="text-xs text-gray-400">{{ $media->formatted_size }}</span>
|
||||
<button wire:click="deleteMedia({{ $media->id }})"
|
||||
class="btn btn-xs btn-ghost text-error"
|
||||
onclick="return confirm('¿Borrar {{ $media->name }}?')">×</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Vacío --}}
|
||||
@if($mediaItems->isEmpty())
|
||||
<div class="text-center text-gray-400 py-6 text-sm">
|
||||
<p class="text-2xl mb-2">📁</p>
|
||||
<p>No hay archivos. Sube imágenes o documentos.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Modal visor de imágenes --}}
|
||||
@if($showViewer && $viewingMedia)
|
||||
<div class="fixed inset-0 z-[5000] bg-black bg-opacity-80 flex items-center justify-center p-4"
|
||||
wire:click.self="closeViewer">
|
||||
<div class="relative max-w-4xl max-h-[90vh]">
|
||||
<button wire:click="closeViewer"
|
||||
class="absolute -top-8 right-0 text-white text-2xl hover:text-gray-300 z-10">✕</button>
|
||||
<img src="{{ $viewingMedia->url }}" alt="{{ $viewingMedia->name }}"
|
||||
class="max-w-full max-h-[85vh] object-contain rounded shadow-2xl" />
|
||||
<div class="text-white text-center text-sm mt-2">
|
||||
{{ $viewingMedia->name }}
|
||||
@if($viewingMedia->description)
|
||||
— {{ $viewingMedia->description }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -46,8 +46,22 @@
|
||||
@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" />
|
||||
🖼️ Mostrar imágenes en mapa
|
||||
@if($featureImageMarkers)
|
||||
<span class="badge badge-xs">{{ count($featureImageMarkers) }}</span>
|
||||
@endif
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Botones generales --}}
|
||||
<div class="mt-3 space-y-1">
|
||||
<div class="mt-2 space-y-1">
|
||||
<a href="{{ route('projects.media', $project) }}" class="btn btn-sm btn-outline w-full">
|
||||
📁 Archivos del proyecto
|
||||
</a>
|
||||
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
|
||||
📍 Centrar mapa
|
||||
</button>
|
||||
@@ -95,6 +109,19 @@
|
||||
💾 Guardar progreso
|
||||
</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">
|
||||
📎 Archivos del elemento
|
||||
</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">Inspección</div>
|
||||
@@ -190,6 +217,8 @@
|
||||
<script>
|
||||
let map;
|
||||
const layers = {};
|
||||
let imageMarkersLayer = null;
|
||||
let imageViewerModal = null;
|
||||
|
||||
function initMap() {
|
||||
if (map) return;
|
||||
@@ -301,6 +330,46 @@
|
||||
|
||||
Livewire.on('centerMap', zoomToAllFeatures);
|
||||
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 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; }
|
||||
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 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Modal para ver imagen al hacer clic
|
||||
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>
|
||||
@endpush
|
||||
Reference in New Issue
Block a user