Sistema de archivos multimedia: MediaManager, checkbox imágenes en mapa, modal visor, subida por feature/proyecto
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use App\Models\Media;
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Phase;
|
||||||
|
use App\Models\Layer;
|
||||||
|
use App\Models\Feature;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class MediaManager extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
// Polimórfico: a qué entidad pertenece
|
||||||
|
public $mediableType;
|
||||||
|
public $mediableId;
|
||||||
|
|
||||||
|
public $entity; // instancia cargada
|
||||||
|
public $mediaItems = [];
|
||||||
|
|
||||||
|
// Subida
|
||||||
|
public $uploadFiles = [];
|
||||||
|
public $uploadDescription = '';
|
||||||
|
public $uploadCategory = 'image';
|
||||||
|
|
||||||
|
// Modal visor
|
||||||
|
public $showViewer = false;
|
||||||
|
public $viewingMedia = null;
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'uploadFiles.*' => 'required|file|max:102400', // 100MB total
|
||||||
|
'uploadDescription' => 'nullable|string|max:500',
|
||||||
|
'uploadCategory' => 'required|in:image,document,other',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $messages = [
|
||||||
|
'uploadFiles.*.max' => 'Cada archivo debe pesar menos de 100MB.',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount($mediableType, $mediableId)
|
||||||
|
{
|
||||||
|
$this->mediableType = $mediableType;
|
||||||
|
$this->mediableId = $mediableId;
|
||||||
|
|
||||||
|
$this->entity = $mediableType::findOrFail($mediableId);
|
||||||
|
$this->loadMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadMedia()
|
||||||
|
{
|
||||||
|
$this->mediaItems = Media::where('mediable_type', $this->mediableType)
|
||||||
|
->where('mediable_id', $this->mediableId)
|
||||||
|
->with('uploader')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user->can('upload layers') && !$user->hasRole('Admin')) {
|
||||||
|
session()->flash('error', 'Sin permisos.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
if (empty($this->uploadFiles)) {
|
||||||
|
session()->flash('error', 'Selecciona al menos un archivo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploaded = 0;
|
||||||
|
foreach ($this->uploadFiles as $file) {
|
||||||
|
$mime = $file->getMimeType();
|
||||||
|
$ext = $file->getClientOriginalExtension();
|
||||||
|
$size = $file->getSize();
|
||||||
|
$name = $file->getClientOriginalName();
|
||||||
|
|
||||||
|
// Determinar categoría automática
|
||||||
|
$category = $this->uploadCategory;
|
||||||
|
if (str_starts_with($mime, 'image/')) {
|
||||||
|
$category = 'image';
|
||||||
|
} elseif (in_array($mime, ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
|
||||||
|
$category = 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guardar en disco
|
||||||
|
$entityType = class_basename($this->entity);
|
||||||
|
$dir = "uploads/{$entityType}s/{$this->mediableId}/media";
|
||||||
|
$path = $file->store($dir, 'public');
|
||||||
|
|
||||||
|
Media::create([
|
||||||
|
'mediable_type' => $this->mediableType,
|
||||||
|
'mediable_id' => $this->mediableId,
|
||||||
|
'name' => $name,
|
||||||
|
'file_path' => $path,
|
||||||
|
'file_type' => $mime,
|
||||||
|
'file_extension' => $ext,
|
||||||
|
'file_size' => $size,
|
||||||
|
'category' => $category,
|
||||||
|
'description' => $this->uploadDescription,
|
||||||
|
'uploaded_by' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$uploaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset(['uploadFiles', 'uploadDescription']);
|
||||||
|
$this->loadMedia();
|
||||||
|
|
||||||
|
// Notificar al mapa si corresponde
|
||||||
|
$this->dispatch('mediaUploaded', [
|
||||||
|
'mediableType' => $this->mediableType,
|
||||||
|
'mediableId' => $this->mediableId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('message', "$uploaded archivo(s) subido(s) correctamente.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteMedia($mediaId)
|
||||||
|
{
|
||||||
|
$media = Media::findOrFail($mediaId);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user->hasRole('Admin') && $media->uploaded_by !== $user->id) {
|
||||||
|
session()->flash('error', 'No puedes borrar archivos de otro usuario.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$media->delete();
|
||||||
|
$this->loadMedia();
|
||||||
|
session()->flash('message', 'Archivo eliminado.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewMedia($mediaId)
|
||||||
|
{
|
||||||
|
$media = Media::findOrFail($mediaId);
|
||||||
|
if (!$media->is_image) {
|
||||||
|
// Si no es imagen, abrir en nueva pestaña
|
||||||
|
$this->dispatch('openUrl', $media->url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->viewingMedia = $media;
|
||||||
|
$this->showViewer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function closeViewer()
|
||||||
|
{
|
||||||
|
$this->showViewer = false;
|
||||||
|
$this->viewingMedia = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.media-manager', [
|
||||||
|
'entityName' => class_basename($this->entity) . ': ' . ($this->entity->name ?? $this->entity->id),
|
||||||
|
'images' => $this->mediaItems->filter(fn($m) => $m->is_image),
|
||||||
|
'documents' => $this->mediaItems->filter(fn($m) => !$m->is_image),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,10 @@ class ProjectMap extends Component
|
|||||||
public $inspectionFormData = [];
|
public $inspectionFormData = [];
|
||||||
public $inspectionHistory = [];
|
public $inspectionHistory = [];
|
||||||
|
|
||||||
|
// Imágenes en mapa
|
||||||
|
public $showFeatureImages = false;
|
||||||
|
public $featureImageMarkers = [];
|
||||||
|
|
||||||
public function mount(Project $project)
|
public function mount(Project $project)
|
||||||
{
|
{
|
||||||
$this->project = $project;
|
$this->project = $project;
|
||||||
@@ -246,6 +250,64 @@ class ProjectMap extends Component
|
|||||||
$this->resetInspectionForm();
|
$this->resetInspectionForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle mostrar imágenes en el mapa.
|
||||||
|
*/
|
||||||
|
public function toggleFeatureImages()
|
||||||
|
{
|
||||||
|
$this->showFeatureImages = !$this->showFeatureImages;
|
||||||
|
$this->loadFeatureImageMarkers();
|
||||||
|
$this->dispatch('featureImagesToggled', $this->showFeatureImages, $this->featureImageMarkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargar marcadores de imágenes para el mapa.
|
||||||
|
*/
|
||||||
|
public function loadFeatureImageMarkers()
|
||||||
|
{
|
||||||
|
if (!$this->showFeatureImages) {
|
||||||
|
$this->featureImageMarkers = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$markers = [];
|
||||||
|
foreach ($this->phases as $phase) {
|
||||||
|
foreach ($phase->layers as $layer) {
|
||||||
|
foreach ($layer->features as $feature) {
|
||||||
|
$image = $feature->images()->first();
|
||||||
|
if ($image) {
|
||||||
|
$geo = $feature->geometry;
|
||||||
|
$coords = null;
|
||||||
|
if ($geo && isset($geo['coordinates'])) {
|
||||||
|
if ($geo['type'] === 'Point') {
|
||||||
|
$coords = [
|
||||||
|
'lat' => $geo['coordinates'][1],
|
||||||
|
'lng' => $geo['coordinates'][0],
|
||||||
|
];
|
||||||
|
} elseif (in_array($geo['type'], ['Polygon', 'LineString'])) {
|
||||||
|
$coords = [
|
||||||
|
'lat' => $geo['coordinates'][0][1] ?? null,
|
||||||
|
'lng' => $geo['coordinates'][0][0] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($coords && $coords['lat'] && $coords['lng']) {
|
||||||
|
$markers[] = [
|
||||||
|
'feature_id' => $feature->id,
|
||||||
|
'name' => $feature->name,
|
||||||
|
'lat' => $coords['lat'],
|
||||||
|
'lng' => $coords['lng'],
|
||||||
|
'image_url' => $image->url,
|
||||||
|
'image_name' => $image->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->featureImageMarkers = $markers;
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleFullscreen()
|
public function toggleFullscreen()
|
||||||
{
|
{
|
||||||
$this->formFullscreen = !$this->formFullscreen;
|
$this->formFullscreen = !$this->formFullscreen;
|
||||||
|
|||||||
@@ -29,4 +29,14 @@ class Feature extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Inspection::class, 'feature_id');
|
return $this->hasMany(Inspection::class, 'feature_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function media()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function images()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -33,4 +33,9 @@ class Layer extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Feature::class);
|
return $this->hasMany(Feature::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function media()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class Media extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'media';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'mediable_type', 'mediable_id',
|
||||||
|
'name', 'file_path', 'file_type', 'file_extension', 'file_size',
|
||||||
|
'category', 'description', 'metadata', 'uploaded_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => 'array',
|
||||||
|
'file_size' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relación polimórfica: pertenece a Project, Phase, Layer o Feature
|
||||||
|
public function mediable()
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploader()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'uploaded_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: URL pública del archivo
|
||||||
|
public function getUrlAttribute()
|
||||||
|
{
|
||||||
|
return Storage::url($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: si es imagen
|
||||||
|
public function getIsImageAttribute()
|
||||||
|
{
|
||||||
|
return str_starts_with($this->file_type, 'image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: tamaño formateado
|
||||||
|
public function getFormattedSizeAttribute()
|
||||||
|
{
|
||||||
|
$bytes = $this->file_size;
|
||||||
|
if ($bytes >= 1073741824) return round($bytes / 1073741824, 2) . ' GB';
|
||||||
|
if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB';
|
||||||
|
if ($bytes >= 1024) return round($bytes / 1024) . ' KB';
|
||||||
|
return $bytes . ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeImages($query)
|
||||||
|
{
|
||||||
|
return $query->where('category', 'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeDocuments($query)
|
||||||
|
{
|
||||||
|
return $query->where('category', 'document');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot: borrar archivo físico al eliminar registro
|
||||||
|
protected static function booted()
|
||||||
|
{
|
||||||
|
static::deleting(function ($media) {
|
||||||
|
Storage::delete($media->file_path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,4 +38,14 @@ class Phase extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasManyThrough(Feature::class, Layer::class);
|
return $this->hasManyThrough(Feature::class, Layer::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function media()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function images()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +39,16 @@ class Project extends Model
|
|||||||
return $this->belongsTo(User::class, 'created_by');
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function media()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function images()
|
||||||
|
{
|
||||||
|
return $this->morphMany(Media::class, 'mediable')->where('category', 'image');
|
||||||
|
}
|
||||||
|
|
||||||
// Scope to filter accessible projects for non-admin users
|
// Scope to filter accessible projects for non-admin users
|
||||||
public function scopeAccessibleBy($query, User $user)
|
public function scopeAccessibleBy($query, User $user)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('media', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('mediable'); // project, phase, layer, feature
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('file_path');
|
||||||
|
$table->string('file_type'); // image/jpeg, application/pdf, etc.
|
||||||
|
$table->string('file_extension', 10);
|
||||||
|
$table->unsignedInteger('file_size');
|
||||||
|
$table->enum('category', ['image', 'document', 'other'])->default('image');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->json('metadata')->nullable(); // EXIF, GPS coords, etc.
|
||||||
|
$table->foreignId('uploaded_by')->constrained('users');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('media');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
@endforeach
|
||||||
</div>
|
</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 --}}
|
{{-- 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">
|
<button wire:click="$dispatch('centerMap')" class="btn btn-sm btn-outline w-full">
|
||||||
📍 Centrar mapa
|
📍 Centrar mapa
|
||||||
</button>
|
</button>
|
||||||
@@ -95,6 +109,19 @@
|
|||||||
💾 Guardar progreso
|
💾 Guardar progreso
|
||||||
</button>
|
</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 --}}
|
{{-- Templates / Inspecciones --}}
|
||||||
@if($templates->isNotEmpty())
|
@if($templates->isNotEmpty())
|
||||||
<div class="divider text-xs">Inspección</div>
|
<div class="divider text-xs">Inspección</div>
|
||||||
@@ -190,6 +217,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let map;
|
let map;
|
||||||
const layers = {};
|
const layers = {};
|
||||||
|
let imageMarkersLayer = null;
|
||||||
|
let imageViewerModal = null;
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (map) return;
|
if (map) return;
|
||||||
@@ -301,6 +330,46 @@
|
|||||||
|
|
||||||
Livewire.on('centerMap', zoomToAllFeatures);
|
Livewire.on('centerMap', zoomToAllFeatures);
|
||||||
Livewire.on('mapResize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
|
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>
|
</script>
|
||||||
@endpush
|
@endpush
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<x-app-layout>
|
||||||
|
<x-slot name="header">
|
||||||
|
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||||
|
Archivos del proyecto: {{ $project->name }}
|
||||||
|
</h2>
|
||||||
|
</x-slot>
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ route('projects.map', $project) }}" class="btn btn-outline btn-sm">← Volver al mapa</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@livewire('media-manager', [
|
||||||
|
'mediableType' => 'App\Models\Project',
|
||||||
|
'mediableId' => $project->id,
|
||||||
|
])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-app-layout>
|
||||||
@@ -88,6 +88,11 @@ Route::middleware(['auth'])->group(function () {
|
|||||||
// Rutas para el LayerManager:
|
// Rutas para el LayerManager:
|
||||||
Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\LayerManager::class)->name('layers.manage');
|
Route::get('/projects/{project}/phases/{phase}/layers/manage', \App\Livewire\LayerManager::class)->name('layers.manage');
|
||||||
|
|
||||||
|
// Gestor de medios
|
||||||
|
Route::get('/projects/{project}/media', function (\App\Models\Project $project) {
|
||||||
|
return view('projects.media', compact('project'));
|
||||||
|
})->name('projects.media');
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Sincronización offline (para trabajadores en campo)
|
// Sincronización offline (para trabajadores en campo)
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user