Add photo management features, update database configuration, and enhance park model seeding

This commit is contained in:
pacnpal
2025-02-25 15:44:21 -05:00
parent 15b2d4ebcf
commit b4462ba89e
31 changed files with 2700 additions and 71 deletions

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Http\Controllers;
use App\Models\Park;
use App\Models\Photo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Encoders\JpegEncoder;
use Illuminate\Support\Str;
class PhotoController extends Controller
{
/**
* Display a listing of the photos for a park.
*/
public function index(Park $park)
{
$photos = $park->photos()->ordered()->get();
return response()->json([
'photos' => $photos,
'featured_photo' => $park->featuredPhoto(),
]);
}
/**
* Store a newly uploaded photo.
*/
public function store(Request $request, Park $park)
{
$request->validate([
'photo' => 'required|image|max:10240', // 10MB max
'title' => 'nullable|string|max:255',
'description' => 'nullable|string',
'alt_text' => 'nullable|string|max:255',
'credit' => 'nullable|string|max:255',
'source_url' => 'nullable|url|max:255',
'is_featured' => 'nullable|boolean',
]);
// Handle file upload
if ($request->hasFile('photo')) {
$file = $request->file('photo');
$originalFilename = $file->getClientOriginalName();
$extension = $file->getClientOriginalExtension();
// Generate a unique filename
$filename = time() . '_' . Str::slug(pathinfo($originalFilename, PATHINFO_FILENAME)) . '.' . $extension;
// Define the storage path
$storagePath = 'photos/parks/' . $park->id;
$fullPath = $storagePath . '/' . $filename;
// Store the original file
$file->storeAs('public/' . $storagePath, $filename);
// Create image manager instance
$manager = new ImageManager(new Driver());
// Get image dimensions
$image = $manager->read($file);
$width = $image->width();
$height = $image->height();
// Generate thumbnail
$thumbnailFilename = pathinfo($filename, PATHINFO_FILENAME) . '_thumb.' . $extension;
$thumbnail = $manager->read($file)->scaleDown(width: 300, height: 300);
// Save thumbnail
$encodedThumbnail = $thumbnail->encode(
new JpegEncoder(80)
);
Storage::put(
'public/' . $storagePath . '/' . $thumbnailFilename,
$encodedThumbnail->toString()
);
// Create photo record
$photo = $park->addPhoto([
'title' => $request->input('title'),
'description' => $request->input('description'),
'file_path' => $fullPath,
'file_name' => $originalFilename,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'width' => $width,
'height' => $height,
'alt_text' => $request->input('alt_text'),
'credit' => $request->input('credit'),
'source_url' => $request->input('source_url'),
'is_featured' => $request->input('is_featured', false),
]);
return response()->json([
'message' => 'Photo uploaded successfully',
'photo' => $photo,
], 201);
}
return response()->json([
'message' => 'No photo file provided',
], 400);
}
/**
* Display the specified photo.
*/
public function show(Photo $photo)
{
return response()->json($photo);
}
/**
* Update the specified photo.
*/
public function update(Request $request, Photo $photo)
{
$request->validate([
'title' => 'nullable|string|max:255',
'description' => 'nullable|string',
'alt_text' => 'nullable|string|max:255',
'credit' => 'nullable|string|max:255',
'source_url' => 'nullable|url|max:255',
'is_featured' => 'nullable|boolean',
]);
$photo->update($request->only([
'title', 'description', 'alt_text', 'credit', 'source_url'
]));
// Handle featured status
if ($request->has('is_featured') && $request->input('is_featured')) {
$photo->setAsFeatured();
}
return response()->json([
'message' => 'Photo updated successfully',
'photo' => $photo,
]);
}
/**
* Remove the specified photo.
*/
public function destroy(Photo $photo)
{
// Get the file paths
$filePath = 'public/' . $photo->file_path;
$pathInfo = pathinfo($photo->file_path);
$thumbnailPath = 'public/' . $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '_thumb.' . $pathInfo['extension'];
// Delete the files
Storage::delete([$filePath, $thumbnailPath]);
// Delete the record
$photo->delete();
return response()->json([
'message' => 'Photo deleted successfully',
]);
}
/**
* Reorder photos for a park.
*/
public function reorder(Request $request, Park $park)
{
$request->validate([
'photo_ids' => 'required|array',
'photo_ids.*' => 'required|integer|exists:photos,id',
]);
$success = $park->reorderPhotos($request->input('photo_ids'));
if ($success) {
return response()->json([
'message' => 'Photos reordered successfully',
]);
}
return response()->json([
'message' => 'Failed to reorder photos',
], 500);
}
/**
* Set a photo as featured.
*/
public function setFeatured(Request $request, Park $park, Photo $photo)
{
if ($photo->photoable_id !== $park->id || $photo->photoable_type !== Park::class) {
return response()->json([
'message' => 'Photo does not belong to this park',
], 400);
}
$success = $park->setFeaturedPhoto($photo);
if ($success) {
return response()->json([
'message' => 'Featured photo set successfully',
]);
}
return response()->json([
'message' => 'Failed to set featured photo',
], 500);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Livewire;
use App\Models\Park;
use App\Models\Photo;
use Livewire\Component;
use Illuminate\Support\Facades\Log;
class FeaturedPhotoSelectorComponent extends Component
{
public Park $park;
public $photos = [];
public $featuredPhotoId = null;
public $isLoading = true;
public $error = null;
public $success = false;
protected $listeners = ['photoUploaded' => 'loadPhotos'];
public function mount(Park $park)
{
$this->park = $park;
$this->loadPhotos();
}
public function loadPhotos()
{
$this->isLoading = true;
$this->error = null;
$this->success = false;
try {
$this->photos = $this->park->photos()->ordered()->get();
$featuredPhoto = $this->park->featuredPhoto();
$this->featuredPhotoId = $featuredPhoto ? $featuredPhoto->id : null;
$this->isLoading = false;
} catch (\Exception $e) {
Log::error('Error loading photos: ' . $e->getMessage());
$this->error = 'Failed to load photos: ' . $e->getMessage();
$this->isLoading = false;
}
}
public function setFeatured($photoId)
{
$this->isLoading = true;
$this->error = null;
$this->success = false;
try {
$photo = Photo::findOrFail($photoId);
if ($photo->photoable_id !== $this->park->id || $photo->photoable_type !== Park::class) {
throw new \Exception('Photo does not belong to this park');
}
$success = $this->park->setFeaturedPhoto($photo);
if ($success) {
$this->featuredPhotoId = $photoId;
$this->success = true;
$this->dispatch('notify', [
'type' => 'success',
'message' => 'Featured photo updated successfully'
]);
// Emit event to refresh other components
$this->dispatch('featuredPhotoChanged');
} else {
throw new \Exception('Failed to set featured photo');
}
} catch (\Exception $e) {
Log::error('Error setting featured photo: ' . $e->getMessage());
$this->error = 'Failed to set featured photo: ' . $e->getMessage();
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Failed to set featured photo'
]);
} finally {
$this->isLoading = false;
}
}
public function render()
{
return view('livewire.featured-photo-selector-component');
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Livewire;
use App\Models\Park;
use App\Models\Photo;
use Livewire\Component;
use Illuminate\Support\Facades\Log;
class PhotoGalleryComponent extends Component
{
public Park $park;
public $photos = [];
public $featuredPhoto = null;
public $selectedPhoto = null;
public $isLoading = true;
public $error = null;
public $viewMode = 'grid'; // grid or carousel
protected $listeners = ['photoUploaded' => 'loadPhotos'];
public function mount(Park $park)
{
$this->park = $park;
$this->loadPhotos();
}
public function loadPhotos()
{
$this->isLoading = true;
$this->error = null;
try {
$this->photos = $this->park->photos()->ordered()->get();
$this->featuredPhoto = $this->park->featuredPhoto();
$this->isLoading = false;
} catch (\Exception $e) {
Log::error('Error loading photos: ' . $e->getMessage());
$this->error = 'Failed to load photos: ' . $e->getMessage();
$this->isLoading = false;
}
}
public function selectPhoto($photoId)
{
$this->selectedPhoto = collect($this->photos)->firstWhere('id', $photoId);
}
public function closePhotoDetail()
{
$this->selectedPhoto = null;
}
public function setFeatured($photoId)
{
try {
$photo = Photo::findOrFail($photoId);
$this->park->setFeaturedPhoto($photo);
$this->loadPhotos();
$this->dispatch('notify', [
'type' => 'success',
'message' => 'Featured photo updated successfully'
]);
} catch (\Exception $e) {
Log::error('Error setting featured photo: ' . $e->getMessage());
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Failed to set featured photo'
]);
}
}
public function deletePhoto($photoId)
{
try {
$photo = Photo::findOrFail($photoId);
// Make API request to the PhotoController
app(\App\Http\Controllers\PhotoController::class)->destroy($photo);
$this->loadPhotos();
$this->dispatch('notify', [
'type' => 'success',
'message' => 'Photo deleted successfully'
]);
if ($this->selectedPhoto && $this->selectedPhoto->id === $photoId) {
$this->selectedPhoto = null;
}
} catch (\Exception $e) {
Log::error('Error deleting photo: ' . $e->getMessage());
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Failed to delete photo: ' . $e->getMessage()
]);
}
}
public function toggleViewMode()
{
$this->viewMode = $this->viewMode === 'grid' ? 'carousel' : 'grid';
}
public function render()
{
return view('livewire.photo-gallery-component');
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Livewire;
use App\Models\Park;
use Livewire\Component;
use Illuminate\Support\Facades\Log;
class PhotoManagerComponent extends Component
{
public Park $park;
public $photos = [];
public $isLoading = true;
public $error = null;
public $reordering = false;
public $photoOrder = [];
protected $listeners = ['photoUploaded' => 'loadPhotos'];
public function mount(Park $park)
{
$this->park = $park;
$this->loadPhotos();
}
public function loadPhotos()
{
$this->isLoading = true;
$this->error = null;
try {
$this->photos = $this->park->photos()->ordered()->get()->toArray();
$this->photoOrder = collect($this->photos)->pluck('id')->toArray();
$this->isLoading = false;
} catch (\Exception $e) {
Log::error('Error loading photos: ' . $e->getMessage());
$this->error = 'Failed to load photos: ' . $e->getMessage();
$this->isLoading = false;
}
}
public function startReordering()
{
$this->reordering = true;
}
public function cancelReordering()
{
$this->reordering = false;
$this->loadPhotos(); // Reset to original order
}
public function saveOrder()
{
try {
// Make API request to the PhotoController
app(\App\Http\Controllers\PhotoController::class)->reorder(
request: new \Illuminate\Http\Request(['photo_ids' => $this->photoOrder]),
park: $this->park
);
$this->reordering = false;
$this->loadPhotos();
$this->dispatch('notify', [
'type' => 'success',
'message' => 'Photo order updated successfully'
]);
} catch (\Exception $e) {
Log::error('Error reordering photos: ' . $e->getMessage());
$this->dispatch('notify', [
'type' => 'error',
'message' => 'Failed to update photo order: ' . $e->getMessage()
]);
}
}
public function moveUp($index)
{
if ($index > 0) {
$temp = $this->photoOrder[$index - 1];
$this->photoOrder[$index - 1] = $this->photoOrder[$index];
$this->photoOrder[$index] = $temp;
}
}
public function moveDown($index)
{
if ($index < count($this->photoOrder) - 1) {
$temp = $this->photoOrder[$index + 1];
$this->photoOrder[$index + 1] = $this->photoOrder[$index];
$this->photoOrder[$index] = $temp;
}
}
public function render()
{
return view('livewire.photo-manager-component');
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Livewire;
use App\Models\Park;
use Livewire\Component;
use Livewire\WithFileUploads;
use Illuminate\Support\Facades\Log;
class PhotoUploadComponent extends Component
{
use WithFileUploads;
public Park $park;
public $photo;
public $title;
public $description;
public $alt_text;
public $credit;
public $source_url;
public $is_featured = false;
public $uploading = false;
public $uploadError = null;
public $uploadSuccess = false;
protected $rules = [
'photo' => 'required|image|max:10240', // 10MB max
'title' => 'nullable|string|max:255',
'description' => 'nullable|string',
'alt_text' => 'nullable|string|max:255',
'credit' => 'nullable|string|max:255',
'source_url' => 'nullable|url|max:255',
'is_featured' => 'boolean',
];
public function mount(Park $park)
{
$this->park = $park;
}
public function updatedPhoto()
{
$this->validate([
'photo' => 'image|max:10240', // 10MB max
]);
}
public function save()
{
$this->uploading = true;
$this->uploadError = null;
$this->uploadSuccess = false;
try {
$this->validate();
// Create form data for the API request
$formData = [
'photo' => $this->photo,
'title' => $this->title,
'description' => $this->description,
'alt_text' => $this->alt_text,
'credit' => $this->credit,
'source_url' => $this->source_url,
'is_featured' => $this->is_featured,
];
// Make API request to the PhotoController
$response = app(\App\Http\Controllers\PhotoController::class)->store(
request: new \Illuminate\Http\Request($formData),
park: $this->park
);
// Reset form
$this->reset(['photo', 'title', 'description', 'alt_text', 'credit', 'source_url', 'is_featured']);
$this->uploadSuccess = true;
// Emit event to refresh the photo gallery
$this->dispatch('photoUploaded');
} catch (\Exception $e) {
Log::error('Photo upload error: ' . $e->getMessage());
$this->uploadError = 'Failed to upload photo: ' . $e->getMessage();
} finally {
$this->uploading = false;
}
}
public function render()
{
return view('livewire.photo-upload-component');
}
}

View File

@@ -8,7 +8,6 @@ use Illuminate\Support\Facades\DB;
class Location extends Model
{
use \Spatie\Activitylog\LogsActivity;
/**
* The attributes that are mass assignable.
@@ -41,7 +40,7 @@ class Location extends Model
* @var array<string, string>
*/
protected $casts = [
'coordinates' => 'point',
// 'coordinates' => 'point', // This cast is not available in Laravel by default
'latitude' => 'decimal:6',
'longitude' => 'decimal:6',
'elevation' => 'decimal:2',
@@ -51,23 +50,6 @@ class Location extends Model
'is_approximate' => 'boolean',
];
/**
* The attributes that should be logged.
*
* @var array
*/
protected static $logAttributes = [
'name',
'location_type',
'address',
'city',
'state',
'country',
'postal_code',
'coordinates',
'latitude',
'longitude',
];
/**
* Boot the model.

View File

@@ -6,8 +6,10 @@ use App\Enums\ParkStatus;
use App\Traits\HasLocation;
use App\Traits\HasSlugHistory;
use App\Traits\HasParkStatistics;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -95,6 +97,95 @@ class Park extends Model
return $this->hasMany(ParkArea::class);
}
/**
* Get the photos for the park.
*/
public function photos(): MorphMany
{
return $this->morphMany(Photo::class, 'photoable');
}
/**
* Get the featured photo for the park.
*/
public function featuredPhoto()
{
return $this->photos()->where('is_featured', true)->first()
?? $this->photos()->orderBy('position')->first();
}
/**
* Get the URL of the featured photo or a default image.
*/
public function getFeaturedPhotoUrlAttribute(): string
{
$photo = $this->featuredPhoto();
return $photo ? $photo->url : asset('images/placeholders/default-park.jpg');
}
/**
* Add a photo to the park.
*
* @param array<string, mixed> $attributes
* @return \App\Models\Photo
*/
public function addPhoto(array $attributes): Photo
{
// Set position to be the last in the collection if not specified
if (!isset($attributes['position'])) {
$lastPosition = $this->photos()->max('position') ?? 0;
$attributes['position'] = $lastPosition + 1;
}
// If this is the first photo or is_featured is true, make it featured
if ($this->photos()->count() === 0 || ($attributes['is_featured'] ?? false)) {
$attributes['is_featured'] = true;
} else {
$attributes['is_featured'] = false;
}
return $this->photos()->create($attributes);
}
/**
* Set a photo as the featured photo.
*
* @param \App\Models\Photo|int $photo
* @return bool
*/
public function setFeaturedPhoto($photo): bool
{
if (is_numeric($photo)) {
$photo = $this->photos()->findOrFail($photo);
}
return $photo->setAsFeatured();
}
/**
* Reorder photos.
*
* @param array<int, int> $photoIds Ordered array of photo IDs
* @return bool
*/
public function reorderPhotos(array $photoIds): bool
{
// Begin transaction
DB::beginTransaction();
try {
foreach ($photoIds as $position => $photoId) {
$this->photos()->where('id', $photoId)->update(['position' => $position + 1]);
}
DB::commit();
return true;
} catch (\Exception $e) {
DB::rollBack();
return false;
}
}
/**
* Get formatted website URL (ensures proper URL format).
*/
@@ -165,6 +256,50 @@ class Park extends Model
]);
}
/**
* Get the formatted location for the park.
*/
public function getFormattedLocationAttribute(): string
{
return $this->formatted_address ?? '';
}
/**
* Get the absolute URL for the park detail page.
*/
public function getAbsoluteUrl(): string
{
return route('parks.show', ['slug' => $this->slug]);
}
/**
* Get a park by its current or historical slug.
*
* @param string $slug
* @return array{0: \App\Models\Park|null, 1: bool} Park and whether a historical slug was used
*/
public static function getBySlug(string $slug): array
{
// Try current slug
$park = static::where('slug', $slug)->first();
if ($park) {
return [$park, false];
}
// Try historical slug
$slugHistory = SlugHistory::where('slug', $slug)
->where('sluggable_type', static::class)
->latest()
->first();
if ($slugHistory) {
$park = static::find($slugHistory->sluggable_id);
return [$park, true];
}
return [null, false];
}
/**
* Boot the model.
*/

131
app/Models/Photo.php Normal file
View File

@@ -0,0 +1,131 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Facades\DB;
class Photo extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<string>
*/
protected $fillable = [
'title',
'description',
'file_path',
'file_name',
'file_size',
'mime_type',
'width',
'height',
'position',
'is_featured',
'alt_text',
'credit',
'source_url',
'metadata',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'width' => 'integer',
'height' => 'integer',
'file_size' => 'integer',
'position' => 'integer',
'is_featured' => 'boolean',
'metadata' => 'array',
];
/**
* Get the parent photoable model.
*/
public function photoable(): MorphTo
{
return $this->morphTo();
}
/**
* Get the URL for the photo.
*/
public function getUrlAttribute(): string
{
return asset('storage/' . $this->file_path);
}
/**
* Get the thumbnail URL for the photo.
*/
public function getThumbnailUrlAttribute(): string
{
$pathInfo = pathinfo($this->file_path);
$thumbnailPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '_thumb.' . $pathInfo['extension'];
return asset('storage/' . $thumbnailPath);
}
/**
* Scope a query to only include featured photos.
*/
public function scopeFeatured($query)
{
return $query->where('is_featured', true);
}
/**
* Scope a query to order by position.
*/
public function scopeOrdered($query)
{
return $query->orderBy('position');
}
/**
* Set this photo as featured and unset others.
*/
public function setAsFeatured(): bool
{
if (!$this->photoable) {
return false;
}
// Begin transaction
DB::beginTransaction();
try {
// Unset all other photos as featured
$this->photoable->photos()
->where('id', '!=', $this->id)
->update(['is_featured' => false]);
// Set this photo as featured
$this->is_featured = true;
$this->save();
DB::commit();
return true;
} catch (\Exception $e) {
DB::rollBack();
return false;
}
}
/**
* Update the position of this photo.
*/
public function updatePosition(int $position): bool
{
$this->position = $position;
return $this->save();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +20,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
// Register the app-layout component
Blade::component('components.layouts.app', 'app-layout');
}
}

View File

@@ -21,7 +21,7 @@ trait HasSlugHistory
static::updating(function ($model) {
if ($model->isDirty('slug') && $model->getOriginal('slug')) {
static::addToSlugHistory($model->getOriginal('slug'));
$model->addToSlugHistory($model->getOriginal('slug'));
}
});
}