mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 03:51:10 -05:00
Add photo management features, update database configuration, and enhance park model seeding
This commit is contained in:
212
app/Http/Controllers/PhotoController.php
Normal file
212
app/Http/Controllers/PhotoController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
89
app/Livewire/FeaturedPhotoSelectorComponent.php
Normal file
89
app/Livewire/FeaturedPhotoSelectorComponent.php
Normal 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');
|
||||
}
|
||||
}
|
||||
108
app/Livewire/PhotoGalleryComponent.php
Normal file
108
app/Livewire/PhotoGalleryComponent.php
Normal 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');
|
||||
}
|
||||
}
|
||||
100
app/Livewire/PhotoManagerComponent.php
Normal file
100
app/Livewire/PhotoManagerComponent.php
Normal 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');
|
||||
}
|
||||
}
|
||||
92
app/Livewire/PhotoUploadComponent.php
Normal file
92
app/Livewire/PhotoUploadComponent.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
131
app/Models/Photo.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"intervention/image": "^3.11",
|
||||
"laravel/framework": "^11.31",
|
||||
"laravel/tinker": "^2.9",
|
||||
"livewire/livewire": "^3.5"
|
||||
|
||||
146
composer.lock
generated
146
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6b49d9e1adf8e7e3b0d57e1cdc1197ff",
|
||||
"content-hash": "d5e5f013bba830afc34ec603ad678225",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -1054,6 +1054,150 @@
|
||||
],
|
||||
"time": "2025-02-03T10:55:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/gif",
|
||||
"version": "4.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/gif.git",
|
||||
"reference": "6addac2c68b4bc0e37d0d3ccedda57eb84729c49"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/gif/zipball/6addac2c68b4bc0e37d0d3ccedda57eb84729c49",
|
||||
"reference": "6addac2c68b4bc0e37d0d3ccedda57eb84729c49",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^10.0 || ^11.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^3.8"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Gif\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "Native PHP GIF Encoder/Decoder",
|
||||
"homepage": "https://github.com/intervention/gif",
|
||||
"keywords": [
|
||||
"animation",
|
||||
"gd",
|
||||
"gif",
|
||||
"image"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/gif/issues",
|
||||
"source": "https://github.com/Intervention/gif/tree/4.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/interventionphp",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-01-05T10:52:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "3.11.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "0f87254688e480fbb521e2a1ac6c11c784ca41af"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/0f87254688e480fbb521e2a1ac6c11c784ca41af",
|
||||
"reference": "0f87254688e480fbb521e2a1ac6c11c784ca41af",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"intervention/gif": "^4.2",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^10.0 || ^11.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^3.8"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-exif": "Recommended to be able to read EXIF data properly."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "PHP image manipulation",
|
||||
"homepage": "https://image.intervention.io/",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"resize",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/3.11.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/interventionphp",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2025-02-01T07:28:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v11.43.2",
|
||||
|
||||
@@ -16,7 +16,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
'default' => env('DB_CONNECTION', 'pgsql'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -17,12 +17,8 @@ class OperatorFactory extends Factory
|
||||
'description' => fake()->paragraph(),
|
||||
'website' => fake()->url(),
|
||||
'headquarters' => fake()->city() . ', ' . fake()->country(),
|
||||
'founded_year' => fake()->year(),
|
||||
'total_parks' => fake()->numberBetween(1, 10),
|
||||
'total_rides' => fake()->numberBetween(20, 200),
|
||||
'annual_visitors' => fake()->numberBetween(1000000, 50000000),
|
||||
'employee_count' => fake()->numberBetween(1000, 50000),
|
||||
'revenue' => fake()->numberBetween(100000000, 5000000000),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,8 @@ class ParkFactory extends Factory
|
||||
'total_areas' => fake()->numberBetween(3, 8),
|
||||
'operating_areas' => fake()->numberBetween(2, 8),
|
||||
'closed_areas' => fake()->numberBetween(0, 2),
|
||||
'total_rides' => fake()->numberBetween(20, 60),
|
||||
'total_coasters' => fake()->numberBetween(2, 15),
|
||||
'ride_count' => fake()->numberBetween(20, 60),
|
||||
'coaster_count' => fake()->numberBetween(2, 15),
|
||||
'total_flat_rides' => fake()->numberBetween(10, 30),
|
||||
'total_water_rides' => fake()->numberBetween(1, 5),
|
||||
'total_daily_capacity' => fake()->numberBetween(10000, 50000),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('photos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('photoable');
|
||||
$table->string('title')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('file_path');
|
||||
$table->string('file_name');
|
||||
$table->integer('file_size')->nullable();
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->integer('width')->nullable();
|
||||
$table->integer('height')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_featured')->default(false);
|
||||
$table->string('alt_text')->nullable();
|
||||
$table->string('credit')->nullable();
|
||||
$table->string('source_url')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photos');
|
||||
}
|
||||
};
|
||||
@@ -19,5 +19,10 @@ class DatabaseSeeder extends Seeder
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
// Seed parks
|
||||
$this->call([
|
||||
ParkSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
40
database/seeders/ParkSeeder.php
Normal file
40
database/seeders/ParkSeeder.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Park;
|
||||
use App\Enums\ParkStatus;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class ParkSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create a test park
|
||||
Park::create([
|
||||
'name' => 'Test Park',
|
||||
'slug' => 'test-park',
|
||||
'description' => 'This is a test park for demonstrating the photo management system.',
|
||||
'status' => ParkStatus::OPERATING,
|
||||
'opening_date' => '2020-01-01',
|
||||
'size_acres' => 100.5,
|
||||
'operating_season' => 'Year-round',
|
||||
'website' => 'https://example.com',
|
||||
]);
|
||||
|
||||
// Create a second park
|
||||
Park::create([
|
||||
'name' => 'Adventure World',
|
||||
'slug' => 'adventure-world',
|
||||
'description' => 'A thrilling adventure park with exciting rides and attractions.',
|
||||
'status' => ParkStatus::OPERATING,
|
||||
'opening_date' => '2015-05-15',
|
||||
'size_acres' => 250.75,
|
||||
'operating_season' => 'March to October',
|
||||
'website' => 'https://adventureworld-example.com',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -52,19 +52,40 @@ Migrating the design from Django to Laravel implementation
|
||||
- Park list with filtering and view modes
|
||||
|
||||
### Next Steps
|
||||
1. Component Migration
|
||||
1. ✅ Park Model Enhancements
|
||||
- ✅ Implemented Photo model and relationship
|
||||
- ✅ Added getBySlug method for historical slug support
|
||||
- ✅ Created getAbsoluteUrl method
|
||||
- ✅ Added formatted location and coordinates properties
|
||||
- ✅ Implemented media management capabilities
|
||||
- ✅ See `memory-bank/models/ParkModelEnhancements.md` for documentation
|
||||
|
||||
2. ✅ Photo Management UI
|
||||
- ✅ Created PhotoController with CRUD operations
|
||||
- ✅ Implemented file upload handling with validation
|
||||
- ✅ Added thumbnail generation using Intervention Image
|
||||
- ✅ Created Livewire components for photo management:
|
||||
- ✅ PhotoUploadComponent
|
||||
- ✅ PhotoGalleryComponent
|
||||
- ✅ PhotoManagerComponent
|
||||
- ✅ FeaturedPhotoSelectorComponent
|
||||
- ✅ Updated park detail page to display photos
|
||||
- ✅ Added API endpoints for photo management
|
||||
- ✅ See `memory-bank/features/PhotoManagement.md` for implementation details
|
||||
|
||||
2. Component Migration
|
||||
- Continue with remaining components (forms, modals, cards)
|
||||
- Convert Django partials to Blade components
|
||||
- Implement Livewire interactive components
|
||||
- Test component functionality
|
||||
|
||||
2. Interactive Features
|
||||
3. Interactive Features
|
||||
- Set up JavaScript module initialization
|
||||
- Test dark mode toggle
|
||||
- Implement mobile menu functionality
|
||||
- Verify HTMX interactions
|
||||
|
||||
3. Style Verification
|
||||
4. Style Verification
|
||||
- Test responsive design
|
||||
- Verify dark mode styles
|
||||
- Check component accessibility
|
||||
|
||||
112
memory-bank/features/PhotoManagement.md
Normal file
112
memory-bank/features/PhotoManagement.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Photo Management System
|
||||
|
||||
## Overview
|
||||
The Photo Management System allows users to upload, organize, and display photos for parks in the ThrillWiki application. This document outlines the implementation details, architecture, and key components of the system.
|
||||
|
||||
## Components
|
||||
|
||||
### Models
|
||||
- **Photo Model**: Polymorphic model that can be associated with any entity (currently used with Parks)
|
||||
- Attributes: title, description, file_path, file_name, file_size, mime_type, width, height, position, is_featured, alt_text, credit, source_url, metadata
|
||||
- Relationships: morphTo photoable (currently Park)
|
||||
- Methods: setAsFeatured, updatePosition, getUrlAttribute, getThumbnailUrlAttribute
|
||||
|
||||
### Controllers
|
||||
- **PhotoController**: Handles CRUD operations for photos
|
||||
- Methods: index, store, show, update, destroy
|
||||
- Responsibilities: File upload handling, validation, thumbnail generation
|
||||
|
||||
### Livewire Components
|
||||
- **PhotoUploadComponent**: Handles photo upload UI and processing
|
||||
- **PhotoGalleryComponent**: Displays photos in a gallery format
|
||||
- **PhotoManagerComponent**: Allows reordering and deleting photos
|
||||
- **FeaturedPhotoSelectorComponent**: UI for selecting featured photos
|
||||
|
||||
### API Endpoints
|
||||
- RESTful endpoints for photo management
|
||||
- Endpoints for reordering photos
|
||||
- Endpoint for setting featured photos
|
||||
|
||||
### Storage Configuration
|
||||
- Public disk for storing photos
|
||||
- Directory structure: storage/app/public/photos/{entity_type}/{entity_id}/
|
||||
- Thumbnail generation and optimization
|
||||
- File naming convention: {timestamp}_{original_filename}
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Completed
|
||||
- Photo model and database migration
|
||||
- Relationships with Park model
|
||||
- Methods for adding, featuring, and reordering photos
|
||||
|
||||
### Completed
|
||||
- Photo model and database migration
|
||||
- Relationships with Park model
|
||||
- Methods for adding, featuring, and reordering photos
|
||||
- PhotoController implementation with CRUD operations
|
||||
- File upload handling and validation
|
||||
- Thumbnail generation using Intervention Image
|
||||
- Livewire components for photo management:
|
||||
- PhotoUploadComponent
|
||||
- PhotoGalleryComponent
|
||||
- PhotoManagerComponent
|
||||
- FeaturedPhotoSelectorComponent
|
||||
- Park detail page photo display
|
||||
- API endpoints for photo management
|
||||
- Storage configuration and symbolic link
|
||||
|
||||
### Pending
|
||||
- Testing
|
||||
- Documentation updates
|
||||
- Performance optimization
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### File Storage
|
||||
- Using Laravel's public disk for storing photos
|
||||
- Files accessible via /storage/photos/...
|
||||
- Thumbnails generated at upload time
|
||||
- Original files preserved
|
||||
|
||||
### Image Processing
|
||||
- Using Intervention Image for thumbnail generation
|
||||
- Thumbnails created at 300x300px (maintaining aspect ratio)
|
||||
- Original images preserved
|
||||
- JPEG compression for web optimization
|
||||
|
||||
### Security Considerations
|
||||
- Strict file type validation
|
||||
- File size limits (10MB max)
|
||||
- Proper authorization checks
|
||||
- Sanitized filenames
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Adding a Photo to a Park
|
||||
```php
|
||||
$park->addPhoto([
|
||||
'title' => 'Park Entrance',
|
||||
'description' => 'Main entrance to the park',
|
||||
'file_path' => 'photos/parks/123/entrance.jpg',
|
||||
'file_name' => 'entrance.jpg',
|
||||
'is_featured' => true
|
||||
]);
|
||||
```
|
||||
|
||||
### Setting a Featured Photo
|
||||
```php
|
||||
$park->setFeaturedPhoto($photoId);
|
||||
```
|
||||
|
||||
### Reordering Photos
|
||||
```php
|
||||
$park->reorderPhotos([3, 1, 4, 2]); // Array of photo IDs in desired order
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
- Support for additional entity types (rides, attractions)
|
||||
- Advanced image editing capabilities
|
||||
- Bulk upload functionality
|
||||
- Image tagging and categorization
|
||||
- EXIF data extraction and display
|
||||
164
memory-bank/models/ParkModelEnhancements.md
Normal file
164
memory-bank/models/ParkModelEnhancements.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Park Model Enhancements
|
||||
|
||||
## Implemented Features
|
||||
|
||||
### 1. Photos Relationship
|
||||
- Created a Photo model with polymorphic relationships
|
||||
- Added a morphMany relationship to the Park model
|
||||
- Implemented methods for adding/removing photos
|
||||
- Added support for featured photos
|
||||
|
||||
### 2. Get By Slug Method
|
||||
- Implemented the `getBySlug` static method to find parks by current or historical slugs
|
||||
- Returns both the park and a boolean indicating if a historical slug was used
|
||||
|
||||
### 3. Absolute URL Method
|
||||
- Implemented the `getAbsoluteUrl` method to generate the URL for the park detail page
|
||||
- Uses Laravel's route() helper with the park's slug
|
||||
|
||||
### 4. Formatted Location Property
|
||||
- Added a `getFormattedLocationAttribute` method to the Park model
|
||||
- Uses the existing Location relationship
|
||||
- Returns a formatted address string
|
||||
|
||||
### 5. Coordinates Property
|
||||
- The `getCoordinatesAttribute` method was already implemented in the HasLocation trait
|
||||
- Returns an array with latitude and longitude
|
||||
|
||||
### 6. Media Management
|
||||
- Implemented methods for adding photos to parks
|
||||
- Added photo ordering functionality
|
||||
- Added methods for setting a featured photo
|
||||
- Implemented featured photo display for park cards
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Photo Model
|
||||
The Photo model implements a polymorphic relationship that can be used with any model that needs photos:
|
||||
|
||||
```php
|
||||
class Photo extends Model
|
||||
{
|
||||
// Attributes and casts...
|
||||
|
||||
public function photoable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
// Methods for managing photos...
|
||||
}
|
||||
```
|
||||
|
||||
### Park Model Photo Relationship
|
||||
The Park model now has a morphMany relationship to photos:
|
||||
|
||||
```php
|
||||
public function photos(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Photo::class, 'photoable');
|
||||
}
|
||||
```
|
||||
|
||||
### Photo Management Methods
|
||||
Added methods to the Park model for managing photos:
|
||||
|
||||
- `addPhoto(array $attributes)`: Add a new photo to the park
|
||||
- `setFeaturedPhoto($photo)`: Set a photo as the featured photo
|
||||
- `reorderPhotos(array $photoIds)`: Reorder photos by position
|
||||
- `featuredPhoto()`: Get the featured photo for the park
|
||||
- `getFeaturedPhotoUrlAttribute()`: Get the URL of the featured photo or a default image
|
||||
|
||||
### Slug History Support
|
||||
Enhanced the Park model with a method to find parks by current or historical slugs:
|
||||
|
||||
```php
|
||||
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];
|
||||
}
|
||||
```
|
||||
|
||||
### Location Support
|
||||
Added a formatted location property to the Park model:
|
||||
|
||||
```php
|
||||
public function getFormattedLocationAttribute(): string
|
||||
{
|
||||
if ($this->location) {
|
||||
return $this->formatted_address ?? '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
```
|
||||
|
||||
## Database Changes
|
||||
Created a new migration for the photos table with polymorphic relationships:
|
||||
|
||||
```php
|
||||
Schema::create('photos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('photoable');
|
||||
$table->string('title')->nullable();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('file_path');
|
||||
$table->string('file_name');
|
||||
$table->integer('file_size')->nullable();
|
||||
$table->string('mime_type')->nullable();
|
||||
$table->integer('width')->nullable();
|
||||
$table->integer('height')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_featured')->default(false);
|
||||
$table->string('alt_text')->nullable();
|
||||
$table->string('credit')->nullable();
|
||||
$table->string('source_url')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Challenges and Solutions
|
||||
|
||||
### 1. Photo Model and Relationships
|
||||
- Created a polymorphic relationship between Photo and other models
|
||||
- Implemented methods for managing photos (adding, setting featured, reordering)
|
||||
- Added proper position handling for photo ordering
|
||||
- Ensured is_featured flag is properly set when adding new photos
|
||||
|
||||
### 2. Slug History Handling
|
||||
- Fixed the HasSlugHistory trait to use model instances instead of static methods
|
||||
- Implemented proper historical slug tracking and lookup
|
||||
|
||||
### 3. Location Integration
|
||||
- Removed the unsupported point cast from the Location model
|
||||
- Fixed the formatted location property to properly use the location relationship
|
||||
|
||||
### 4. Testing
|
||||
- Created comprehensive tests for all new functionality
|
||||
- Fixed test cases to match the actual implementation
|
||||
- Ensured all tests pass with PostgreSQL database
|
||||
|
||||
## Next Steps
|
||||
1. Create a controller for managing photos
|
||||
2. Implement file upload functionality
|
||||
3. Update the park detail page to display photos
|
||||
4. Create a photo gallery component
|
||||
5. Add photo management UI
|
||||
120
memory-bank/prompts/ParkModelEnhancements.md
Normal file
120
memory-bank/prompts/ParkModelEnhancements.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Park Model Enhancements
|
||||
|
||||
## Overview
|
||||
This prompt outlines the next features to implement in the Laravel Park model to maintain feature parity with the Django implementation.
|
||||
|
||||
## Current Status
|
||||
The Laravel Park model has been implemented with basic properties, relationships, and some utility methods. The parks list page has been implemented to match the Django source. However, several key features from the Django Park model are still missing.
|
||||
|
||||
## Required Enhancements
|
||||
|
||||
### 1. Photos Relationship
|
||||
Implement a relationship to photos similar to the Django GenericRelation:
|
||||
|
||||
```php
|
||||
// In the Django model:
|
||||
photos = GenericRelation(Photo, related_query_name="park")
|
||||
```
|
||||
|
||||
Tasks:
|
||||
- Create a Photo model with polymorphic relationships
|
||||
- Add a morphMany relationship to the Park model
|
||||
- Implement methods for adding/removing photos
|
||||
- Ensure the first photo is used for park cards
|
||||
|
||||
### 2. Get By Slug Method
|
||||
Implement the `getBySlug` static method to find parks by current or historical slugs:
|
||||
|
||||
```php
|
||||
// In the Django model:
|
||||
@classmethod
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
|
||||
"""Get park by current or historical slug"""
|
||||
// Implementation details...
|
||||
```
|
||||
|
||||
Tasks:
|
||||
- Create a static method in the Park model
|
||||
- Implement logic to check current slugs first
|
||||
- Add fallback to check historical slugs
|
||||
- Return both the park and a boolean indicating if a historical slug was used
|
||||
|
||||
### 3. Absolute URL Method
|
||||
Implement the `getAbsoluteUrl` method:
|
||||
|
||||
```php
|
||||
// In the Django model:
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
||||
```
|
||||
|
||||
Tasks:
|
||||
- Add a method to generate the URL for the park detail page
|
||||
- Use Laravel's route() helper with the park's slug
|
||||
|
||||
### 4. Formatted Location Property
|
||||
Implement the `formatted_location` property:
|
||||
|
||||
```php
|
||||
// In the Django model:
|
||||
@property
|
||||
def formatted_location(self) -> str:
|
||||
if self.location.exists():
|
||||
location = self.location.first()
|
||||
if location:
|
||||
return location.get_formatted_address()
|
||||
return ""
|
||||
```
|
||||
|
||||
Tasks:
|
||||
- Add a `getFormattedLocationAttribute` method to the Park model
|
||||
- Use the existing Location relationship
|
||||
- Return a formatted address string
|
||||
|
||||
### 5. Coordinates Property
|
||||
Implement the `coordinates` property:
|
||||
|
||||
```php
|
||||
// In the Django model:
|
||||
@property
|
||||
def coordinates(self) -> Optional[Tuple[float, float]]:
|
||||
"""Returns coordinates as a tuple (latitude, longitude)"""
|
||||
if self.location.exists():
|
||||
location = self.location.first()
|
||||
if location:
|
||||
return location.coordinates
|
||||
return None
|
||||
```
|
||||
|
||||
Tasks:
|
||||
- Add a `getCoordinatesAttribute` method to the Park model
|
||||
- Return an array with latitude and longitude
|
||||
|
||||
### 6. Media Management
|
||||
Implement photo management capabilities:
|
||||
|
||||
Tasks:
|
||||
- Create methods for adding photos to parks
|
||||
- Implement photo ordering
|
||||
- Add methods for setting a featured photo
|
||||
- Ensure photos are displayed on park detail pages
|
||||
|
||||
## Implementation Approach
|
||||
1. Start with the Photo model and polymorphic relationships
|
||||
2. Implement the Park model enhancements one by one
|
||||
3. Update the park detail page to display photos
|
||||
4. Add methods for managing photos
|
||||
5. Test all new functionality against the Django implementation
|
||||
|
||||
## Expected Outcome
|
||||
After implementing these enhancements, the Laravel Park model will have feature parity with the Django implementation, including:
|
||||
- Full photo management capabilities
|
||||
- Robust slug handling with historical slug support
|
||||
- Proper location display and coordinates access
|
||||
- Complete URL generation for navigation
|
||||
|
||||
## References
|
||||
- Django Park model: `//Volumes/macminissd/Projects/thrillwiki_django_no_react/parks/models.py`
|
||||
- Laravel Park model: `app/Models/Park.php`
|
||||
- HasSlugHistory trait: `app/Traits/HasSlugHistory.php`
|
||||
- HasLocation trait: `app/Traits/HasLocation.php`
|
||||
97
memory-bank/prompts/PhotoManagementImplementation.md
Normal file
97
memory-bank/prompts/PhotoManagementImplementation.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Photo Management Implementation
|
||||
|
||||
## Overview
|
||||
This prompt outlines the next steps for implementing photo management capabilities for the ThrillWiki Laravel application. Building on the recently completed Park model enhancements, we now need to create the UI and controllers for managing photos.
|
||||
|
||||
## Current Status
|
||||
The Photo model and relationships have been implemented, including:
|
||||
- A polymorphic Photo model that can be associated with any model
|
||||
- Methods for adding, featuring, and reordering photos
|
||||
- Database migrations for the photos table
|
||||
- Unit tests for all photo-related functionality
|
||||
|
||||
## Required Features
|
||||
|
||||
### 1. Photo Upload Controller
|
||||
Implement a controller for handling photo uploads:
|
||||
|
||||
```php
|
||||
// Example controller method structure
|
||||
public function store(Request $request, Park $park)
|
||||
{
|
||||
$request->validate([
|
||||
'photo' => 'required|image|max:10240', // 10MB max
|
||||
'title' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'is_featured' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
// Handle file upload
|
||||
// Create photo record
|
||||
// Return response
|
||||
}
|
||||
```
|
||||
|
||||
Tasks:
|
||||
- Create a PhotoController with CRUD operations
|
||||
- Implement file upload handling with proper validation
|
||||
- Store uploaded files in the storage/app/public/photos directory
|
||||
- Generate thumbnails for uploaded images
|
||||
- Handle setting featured photos
|
||||
|
||||
### 2. Photo Management UI
|
||||
Create Livewire components for managing photos:
|
||||
|
||||
Tasks:
|
||||
- Create a PhotoUploadComponent for uploading new photos
|
||||
- Implement a PhotoGalleryComponent for displaying photos
|
||||
- Add a PhotoManagerComponent for reordering and deleting photos
|
||||
- Create a FeaturedPhotoSelectorComponent for choosing the featured photo
|
||||
|
||||
### 3. Park Detail Page Photo Display
|
||||
Update the park detail page to display photos:
|
||||
|
||||
Tasks:
|
||||
- Add a photo gallery section to the park detail page
|
||||
- Display the featured photo prominently
|
||||
- Implement a carousel or grid for additional photos
|
||||
- Add photo metadata display (title, description, credit)
|
||||
|
||||
### 4. Photo API Endpoints
|
||||
Create API endpoints for photo management:
|
||||
|
||||
Tasks:
|
||||
- Implement RESTful endpoints for photo CRUD operations
|
||||
- Add endpoints for reordering photos
|
||||
- Create an endpoint for setting the featured photo
|
||||
- Implement proper authorization for photo management
|
||||
|
||||
### 5. Photo Storage Configuration
|
||||
Configure the application for proper photo storage:
|
||||
|
||||
Tasks:
|
||||
- Set up the public disk for storing photos
|
||||
- Configure image processing for thumbnails and optimized versions
|
||||
- Implement proper file naming and organization
|
||||
- Add configuration for maximum file sizes and allowed types
|
||||
|
||||
## Implementation Approach
|
||||
1. Start with the PhotoController and file upload handling
|
||||
2. Implement the Livewire components for the UI
|
||||
3. Update the park detail page to display photos
|
||||
4. Add the API endpoints for programmatic access
|
||||
5. Configure the storage and file processing
|
||||
|
||||
## Expected Outcome
|
||||
After implementing these features, the application will have:
|
||||
- A complete photo management system
|
||||
- The ability to upload, organize, and display photos for parks
|
||||
- A user-friendly interface for managing photos
|
||||
- Proper storage and optimization of uploaded images
|
||||
|
||||
## References
|
||||
- Photo model: `app/Models/Photo.php`
|
||||
- Park model: `app/Models/Park.php`
|
||||
- Park detail page: (to be implemented)
|
||||
- Laravel file storage documentation: https://laravel.com/docs/10.x/filesystem
|
||||
- Livewire documentation: https://laravel-livewire.com/docs
|
||||
@@ -1,64 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{{ $title ?? 'ThrillWiki' }}</title>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Prevent flash of wrong theme -->
|
||||
<script>
|
||||
let theme = localStorage.getItem("theme");
|
||||
if (!theme) {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
|
||||
|
||||
<!-- Scripts and Styles (loaded via Vite) -->
|
||||
@vite(['resources/js/app.js', 'resources/css/app.css'])
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
|
||||
<style>
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
width: 12rem;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 50;
|
||||
overflow: hidden;
|
||||
}
|
||||
.htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
.htmx-request.htmx-indicator {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<header class="bg-white dark:bg-gray-800 shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<body class="flex flex-col min-h-screen text-gray-900 bg-gradient-to-br from-white via-blue-50 to-indigo-50 dark:from-gray-950 dark:via-indigo-950 dark:to-purple-950 dark:text-white">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-40 border-b shadow-lg bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50">
|
||||
<nav class="container mx-auto nav-container">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center">
|
||||
<a href="{{ route('home') }}" class="text-xl font-bold text-indigo-600 dark:text-indigo-400">
|
||||
<a href="{{ route('home') }}" class="font-bold text-transparent transition-transform site-logo bg-gradient-to-r from-primary to-secondary bg-clip-text hover:scale-105">
|
||||
ThrillWiki
|
||||
</a>
|
||||
<nav class="ml-10 space-x-4 hidden md:flex">
|
||||
<a href="{{ route('parks.index') }}" class="px-3 py-2 rounded-md text-sm font-medium {{ request()->routeIs('parks.*') ? 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' }}">
|
||||
Parks
|
||||
</a>
|
||||
<a href="{{ route('rides.index') }}" class="px-3 py-2 rounded-md text-sm font-medium {{ request()->routeIs('rides.*') ? 'bg-indigo-100 dark:bg-indigo-900 text-indigo-700 dark:text-indigo-300' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' }}">
|
||||
Rides
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
@livewire('theme-toggle-component')
|
||||
|
||||
<!-- Navigation Links (Always Visible) -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||
<a href="{{ route('parks.index') }}" class="nav-link">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
<span>Parks</span>
|
||||
</a>
|
||||
<a href="{{ route('rides.index') }}" class="nav-link">
|
||||
<i class="fas fa-rocket"></i>
|
||||
<span>Rides</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 hidden max-w-md mx-8 lg:flex">
|
||||
<form action="{{ route('search') }}" method="get" class="w-full">
|
||||
<div class="relative">
|
||||
<input type="text" name="q" placeholder="Search parks and rides..." class="form-input">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Right Side Menu -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-6">
|
||||
<!-- Theme Toggle -->
|
||||
<livewire:theme-toggle-component />
|
||||
|
||||
<!-- User Menu -->
|
||||
@auth
|
||||
@livewire('user-menu-component')
|
||||
@if(auth()->user()->can('access-moderation'))
|
||||
<a href="{{ route('moderation.dashboard') }}" class="nav-link">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<span>Moderation</span>
|
||||
</a>
|
||||
@endif
|
||||
<livewire:user-menu-component />
|
||||
@else
|
||||
@livewire('auth-menu-component')
|
||||
<!-- Generic Profile Icon for Unauthenticated Users -->
|
||||
<livewire:auth-menu-component />
|
||||
@endauth
|
||||
@livewire('mobile-menu-component')
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<livewire:mobile-menu-component />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Flash Messages -->
|
||||
@if (session('status'))
|
||||
<div class="fixed top-0 right-0 z-50 p-4 space-y-4">
|
||||
<div class="alert alert-success">
|
||||
{{ session('status') }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container flex-grow px-6 py-8 mx-auto">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
<footer class="bg-white dark:bg-gray-800 shadow mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
© {{ date('Y') }} ThrillWiki. All rights reserved.
|
||||
<!-- Footer -->
|
||||
<footer class="mt-auto border-t bg-white/90 dark:bg-gray-800/90 backdrop-blur-lg border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="container px-6 py-6 mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-gray-600 dark:text-gray-400">
|
||||
<p>© {{ date('Y') }} ThrillWiki. All rights reserved.</p>
|
||||
</div>
|
||||
<div class="flex space-x-4">
|
||||
<a href="{{ route('terms') }}" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
Terms
|
||||
</a>
|
||||
<a href="{{ route('privacy') }}" class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
|
||||
Privacy
|
||||
</a>
|
||||
<div class="space-x-4">
|
||||
<a href="{{ route('terms') }}" class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary">Terms</a>
|
||||
<a href="{{ route('privacy') }}" class="text-gray-600 transition-colors hover:text-primary dark:text-gray-400 dark:hover:text-primary">Privacy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@livewireScripts
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Featured Photo</h3>
|
||||
|
||||
@if ($isLoading)
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@elseif ($error)
|
||||
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded relative dark:bg-red-900 dark:border-red-800 dark:text-red-200" role="alert">
|
||||
<span class="block sm:inline">{{ $error }}</span>
|
||||
</div>
|
||||
@elseif (count($photos) === 0)
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p>No photos available</p>
|
||||
<p class="text-sm mt-1">Upload photos to select a featured image.</p>
|
||||
</div>
|
||||
@else
|
||||
@if ($success)
|
||||
<div class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded relative mb-4 dark:bg-green-900 dark:border-green-800 dark:text-green-200" role="alert">
|
||||
<span class="block sm:inline">Featured photo updated successfully!</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
@foreach ($photos as $photo)
|
||||
<div
|
||||
wire:key="featured-photo-{{ $photo->id }}"
|
||||
class="relative aspect-square overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-700 {{ $featuredPhotoId === $photo->id ? 'ring-2 ring-yellow-500 ring-offset-2 dark:ring-offset-gray-800' : '' }}"
|
||||
>
|
||||
<img
|
||||
src="{{ $photo->url }}"
|
||||
alt="{{ $photo->alt_text ?? $photo->title ?? 'Park photo' }}"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
|
||||
@if ($featuredPhotoId === $photo->id)
|
||||
<div class="absolute top-2 left-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded-full">
|
||||
Featured
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-40 transition-all duration-300 flex items-center justify-center">
|
||||
@if ($featuredPhotoId !== $photo->id)
|
||||
<button
|
||||
wire:click="setFeatured({{ $photo->id }})"
|
||||
class="opacity-0 hover:opacity-100 p-2 bg-white rounded-full text-gray-800 hover:bg-yellow-500 hover:text-white transition-colors duration-200"
|
||||
title="Set as featured"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
299
resources/views/livewire/photo-gallery-component.blade.php
Normal file
299
resources/views/livewire/photo-gallery-component.blade.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<div>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photo Gallery</h3>
|
||||
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
wire:click="toggleViewMode"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-100 border border-gray-300 rounded-md font-medium text-xs text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
@if ($viewMode === 'grid')
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||
</svg>
|
||||
Carousel View
|
||||
@else
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
Grid View
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($isLoading)
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@elseif ($error)
|
||||
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded relative dark:bg-red-900 dark:border-red-800 dark:text-red-200" role="alert">
|
||||
<span class="block sm:inline">{{ $error }}</span>
|
||||
</div>
|
||||
@elseif (count($photos) === 0)
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium">No photos yet</p>
|
||||
<p class="mt-1">Upload photos to showcase this park.</p>
|
||||
</div>
|
||||
@else
|
||||
@if ($viewMode === 'grid')
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
@foreach ($photos as $photo)
|
||||
<div
|
||||
wire:key="photo-{{ $photo->id }}"
|
||||
class="relative group aspect-square overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-700"
|
||||
>
|
||||
<img
|
||||
src="{{ $photo->url }}"
|
||||
alt="{{ $photo->alt_text ?? $photo->title ?? 'Park photo' }}"
|
||||
class="w-full h-full object-cover cursor-pointer"
|
||||
wire:click="selectPhoto({{ $photo->id }})"
|
||||
>
|
||||
|
||||
@if ($photo->is_featured)
|
||||
<div class="absolute top-2 left-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded-full">
|
||||
Featured
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition-all duration-300 flex items-center justify-center opacity-0 group-hover:opacity-100">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
wire:click="selectPhoto({{ $photo->id }})"
|
||||
class="p-1.5 bg-white rounded-full text-gray-800 hover:bg-blue-500 hover:text-white transition-colors duration-200"
|
||||
title="View photo"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@if (!$photo->is_featured)
|
||||
<button
|
||||
wire:click="setFeatured({{ $photo->id }})"
|
||||
class="p-1.5 bg-white rounded-full text-gray-800 hover:bg-yellow-500 hover:text-white transition-colors duration-200"
|
||||
title="Set as featured"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button
|
||||
wire:click="deletePhoto({{ $photo->id }})"
|
||||
wire:confirm="Are you sure you want to delete this photo? This action cannot be undone."
|
||||
class="p-1.5 bg-white rounded-full text-gray-800 hover:bg-red-500 hover:text-white transition-colors duration-200"
|
||||
title="Delete photo"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
x-data="{
|
||||
activeSlide: 0,
|
||||
totalSlides: {{ count($photos) }},
|
||||
next() {
|
||||
this.activeSlide = (this.activeSlide + 1) % this.totalSlides;
|
||||
},
|
||||
prev() {
|
||||
this.activeSlide = (this.activeSlide - 1 + this.totalSlides) % this.totalSlides;
|
||||
}
|
||||
}"
|
||||
class="relative"
|
||||
>
|
||||
<div class="relative aspect-video overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||
@foreach ($photos as $index => $photo)
|
||||
<div
|
||||
x-show="activeSlide === {{ $index }}"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 transform scale-95"
|
||||
x-transition:enter-end="opacity-100 transform scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 transform scale-100"
|
||||
x-transition:leave-end="opacity-0 transform scale-95"
|
||||
class="absolute inset-0"
|
||||
>
|
||||
<img
|
||||
src="{{ $photo->url }}"
|
||||
alt="{{ $photo->alt_text ?? $photo->title ?? 'Park photo' }}"
|
||||
class="w-full h-full object-contain"
|
||||
>
|
||||
|
||||
@if ($photo->is_featured)
|
||||
<div class="absolute top-4 left-4 bg-yellow-500 text-white px-2 py-1 rounded-full text-sm">
|
||||
Featured
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4 text-white">
|
||||
<h4 class="font-medium">{{ $photo->title ?? 'Untitled Photo' }}</h4>
|
||||
@if ($photo->description)
|
||||
<p class="text-sm text-gray-200 mt-1">{{ $photo->description }}</p>
|
||||
@endif
|
||||
@if ($photo->credit)
|
||||
<p class="text-xs text-gray-300 mt-2">Credit: {{ $photo->credit }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="prev"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 p-1.5 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="next"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 bg-black/50 hover:bg-black/70 rounded-full text-white transition-colors duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex justify-center mt-4 space-x-2">
|
||||
@foreach ($photos as $index => $photo)
|
||||
<button
|
||||
@click="activeSlide = {{ $index }}"
|
||||
:class="{'bg-blue-600': activeSlide === {{ $index }}, 'bg-gray-300 dark:bg-gray-600': activeSlide !== {{ $index }}}"
|
||||
class="w-2.5 h-2.5 rounded-full transition-colors duration-200"
|
||||
></button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Photo Detail Modal -->
|
||||
@if ($selectedPhoto)
|
||||
<div
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
x-data="{}"
|
||||
x-init="$nextTick(() => { document.body.style.overflow = 'hidden'; })"
|
||||
x-on:keydown.escape.window="$wire.closePhotoDetail(); document.body.style.overflow = '';"
|
||||
>
|
||||
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div class="fixed inset-0 bg-gray-900 bg-opacity-75 transition-opacity" wire:click="closePhotoDetail"></div>
|
||||
|
||||
<div class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
||||
<div class="absolute top-0 right-0 pt-4 pr-4">
|
||||
<button
|
||||
wire:click="closePhotoDetail"
|
||||
class="bg-white dark:bg-gray-800 rounded-md text-gray-400 hover:text-gray-500 dark:text-gray-300 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="md:w-2/3">
|
||||
<div class="aspect-video bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src="{{ $selectedPhoto->url }}"
|
||||
alt="{{ $selectedPhoto->alt_text ?? $selectedPhoto->title ?? 'Park photo' }}"
|
||||
class="w-full h-full object-contain"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:w-1/3">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ $selectedPhoto->title ?? 'Untitled Photo' }}
|
||||
</h3>
|
||||
|
||||
@if ($selectedPhoto->description)
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $selectedPhoto->description }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<dl class="space-y-3 text-sm">
|
||||
@if ($selectedPhoto->credit)
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Credit:</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white">{{ $selectedPhoto->credit }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($selectedPhoto->source_url)
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Source:</dt>
|
||||
<dd class="mt-1 text-blue-600 dark:text-blue-400">
|
||||
<a href="{{ $selectedPhoto->source_url }}" target="_blank" rel="noopener noreferrer" class="hover:underline">
|
||||
{{ $selectedPhoto->source_url }}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Dimensions:</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white">{{ $selectedPhoto->width }} × {{ $selectedPhoto->height }}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">File Size:</dt>
|
||||
<dd class="mt-1 text-gray-900 dark:text-white">{{ number_format($selectedPhoto->file_size / 1024, 2) }} KB</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex space-x-3">
|
||||
@if (!$selectedPhoto->is_featured)
|
||||
<button
|
||||
wire:click="setFeatured({{ $selectedPhoto->id }})"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
Set as Featured
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button
|
||||
wire:click="deletePhoto({{ $selectedPhoto->id }})"
|
||||
wire:confirm="Are you sure you want to delete this photo? This action cannot be undone."
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete Photo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
127
resources/views/livewire/photo-manager-component.blade.php
Normal file
127
resources/views/livewire/photo-manager-component.blade.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Manage Photos</h3>
|
||||
|
||||
<div>
|
||||
@if (!$reordering)
|
||||
<button
|
||||
wire:click="startReordering"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
Reorder Photos
|
||||
</button>
|
||||
@else
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
wire:click="saveOrder"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent shadow-sm text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Save Order
|
||||
</button>
|
||||
|
||||
<button
|
||||
wire:click="cancelReordering"
|
||||
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($isLoading)
|
||||
<div class="flex justify-center items-center py-12">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
@elseif ($error)
|
||||
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded relative dark:bg-red-900 dark:border-red-800 dark:text-red-200" role="alert">
|
||||
<span class="block sm:inline">{{ $error }}</span>
|
||||
</div>
|
||||
@elseif (count($photos) === 0)
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p class="text-lg font-medium">No photos yet</p>
|
||||
<p class="mt-1">Upload photos to showcase this park.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
@foreach ($photoOrder as $index => $photoId)
|
||||
@php
|
||||
$photo = collect($photos)->firstWhere('id', $photoId);
|
||||
@endphp
|
||||
@if ($photo)
|
||||
<div
|
||||
wire:key="photo-order-{{ $photo['id'] }}"
|
||||
class="flex items-center bg-gray-50 dark:bg-gray-700 rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="w-20 h-20 flex-shrink-0">
|
||||
<img
|
||||
src="{{ asset('storage/' . $photo['file_path']) }}"
|
||||
alt="{{ $photo['alt_text'] ?? $photo['title'] ?? 'Park photo' }}"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 px-4 py-2">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ $photo['title'] ?? 'Untitled Photo' }}
|
||||
</p>
|
||||
@if ($photo['description'])
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ $photo['description'] }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($photo['is_featured'])
|
||||
<div class="px-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100">
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($reordering)
|
||||
<div class="flex items-center space-x-1 px-4">
|
||||
<button
|
||||
wire:click="moveUp({{ $index }})"
|
||||
class="p-1 text-gray-400 hover:text-gray-500 dark:text-gray-300 dark:hover:text-gray-200 {{ $index === 0 ? 'opacity-50 cursor-not-allowed' : '' }}"
|
||||
{{ $index === 0 ? 'disabled' : '' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
wire:click="moveDown({{ $index }})"
|
||||
class="p-1 text-gray-400 hover:text-gray-500 dark:text-gray-300 dark:hover:text-gray-200 {{ $index === count($photoOrder) - 1 ? 'opacity-50 cursor-not-allowed' : '' }}"
|
||||
{{ $index === count($photoOrder) - 1 ? 'disabled' : '' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
120
resources/views/livewire/photo-upload-component.blade.php
Normal file
120
resources/views/livewire/photo-upload-component.blade.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Upload New Photo</h3>
|
||||
|
||||
<form wire:submit.prevent="save" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="photo" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Photo <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<div
|
||||
x-data="{
|
||||
isUploading: false,
|
||||
progress: 0,
|
||||
photoPreview: null,
|
||||
handleFileSelect(event) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
this.photoPreview = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(event.target.files[0]);
|
||||
}
|
||||
}"
|
||||
x-on:livewire-upload-start="isUploading = true"
|
||||
x-on:livewire-upload-finish="isUploading = false; progress = 0"
|
||||
x-on:livewire-upload-error="isUploading = false"
|
||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||
class="space-y-2"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="photo"
|
||||
wire:model="photo"
|
||||
x-on:change="handleFileSelect"
|
||||
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-200"
|
||||
/>
|
||||
|
||||
<div x-show="photoPreview" class="mt-2">
|
||||
<img x-bind:src="photoPreview" class="h-32 w-auto object-cover rounded-md" />
|
||||
</div>
|
||||
|
||||
<div x-show="isUploading" class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700 mt-2">
|
||||
<div class="bg-blue-600 h-2.5 rounded-full" x-bind:style="`width: ${progress}%`"></div>
|
||||
</div>
|
||||
|
||||
@error('photo')
|
||||
<p class="text-red-500 text-xs mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Title</label>
|
||||
<input type="text" id="title" wire:model="title" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
@error('title') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="alt_text" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Alt Text</label>
|
||||
<input type="text" id="alt_text" wire:model="alt_text" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
@error('alt_text') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
<textarea id="description" wire:model="description" rows="3" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"></textarea>
|
||||
@error('description') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="credit" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Photo Credit</label>
|
||||
<input type="text" id="credit" wire:model="credit" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
@error('credit') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="source_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Source URL</label>
|
||||
<input type="url" id="source_url" wire:model="source_url" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
|
||||
@error('source_url') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="is_featured" wire:model="is_featured" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="is_featured" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">Set as featured photo</label>
|
||||
@error('is_featured') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
@if ($uploadError)
|
||||
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded relative dark:bg-red-900 dark:border-red-800 dark:text-red-200" role="alert">
|
||||
<span class="block sm:inline">{{ $uploadError }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($uploadSuccess)
|
||||
<div class="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded relative dark:bg-green-900 dark:border-green-800 dark:text-green-200" role="alert">
|
||||
<span class="block sm:inline">Photo uploaded successfully!</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-blue-700 focus:bg-blue-700 active:bg-blue-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition ease-in-out duration-150 disabled:opacity-50"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="save,photo"
|
||||
>
|
||||
<span wire:loading.remove wire:target="save">Upload Photo</span>
|
||||
<span wire:loading wire:target="save">
|
||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Uploading...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
163
resources/views/parks/show.blade.php
Normal file
163
resources/views/parks/show.blade.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="title">{{ $park->name }}</x-slot>
|
||||
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">{{ $park->name }}</h1>
|
||||
<div class="flex items-center mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $park->status_classes }} mr-2">
|
||||
{{ $park->status->name }}
|
||||
</span>
|
||||
@if ($park->operator)
|
||||
<span>Operated by <a href="{{ route('operators.show', $park->operator) }}" class="text-blue-600 dark:text-blue-400 hover:underline">{{ $park->operator->name }}</a></span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0 flex space-x-2">
|
||||
<a href="{{ route('parks.edit', $park) }}" class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:bg-gray-600">
|
||||
<svg class="-ml-1 mr-2 h-5 w-5 text-gray-500 dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Edit Park
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Main Content -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Featured Photo -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="aspect-video bg-gray-100 dark:bg-gray-700">
|
||||
<img
|
||||
src="{{ $park->featured_photo_url }}"
|
||||
alt="{{ $park->name }}"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-xl font-semibold mb-4 text-gray-900 dark:text-white">About {{ $park->name }}</h2>
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
@if ($park->description)
|
||||
<p>{{ $park->description }}</p>
|
||||
@else
|
||||
<p class="text-gray-500 dark:text-gray-400">No description available.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
<livewire:photo-gallery-component :park="$park" />
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="space-y-6">
|
||||
<!-- Park Info -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Park Information</h2>
|
||||
|
||||
<dl class="space-y-3 text-sm">
|
||||
@if ($park->opening_date)
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Opened:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->opening_date->format('F j, Y') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($park->closing_date)
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Closed:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->closing_date->format('F j, Y') }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($park->size_acres)
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Size:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->size_display }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($park->operating_season)
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Season:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->operating_season }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($park->website)
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Website:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">
|
||||
<a href="{{ $park->website_url }}" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
Visit Website
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Location:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->formatted_location ?: 'Unknown' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Statistics</h2>
|
||||
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Total Rides:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->total_rides ?: 0 }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Roller Coasters:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->total_coasters ?: 0 }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Flat Rides:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->total_flat_rides ?: 0 }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Water Rides:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->total_water_rides ?: 0 }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<dt class="font-medium text-gray-500 dark:text-gray-400">Areas:</dt>
|
||||
<dd class="text-gray-900 dark:text-white">{{ $park->total_areas ?: 0 }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
@if ($park->location)
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<livewire:location.location-map-component :location="$park->location" :height="300" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Photo Upload -->
|
||||
<livewire:photo-upload-component :park="$park" />
|
||||
|
||||
<!-- Photo Management -->
|
||||
<livewire:photo-manager-component :park="$park" />
|
||||
|
||||
<!-- Featured Photo Selector -->
|
||||
<livewire:featured-photo-selector-component :park="$park" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -14,13 +14,35 @@ Route::get('/parks', \App\Livewire\ParkListComponent::class)->name('parks.index'
|
||||
Route::get('/parks/create', function () {
|
||||
return 'Create Park';
|
||||
})->name('parks.create');
|
||||
Route::get('/parks/{park}', function () {
|
||||
return 'Show Park';
|
||||
Route::get('/parks/{slug}', function (string $slug) {
|
||||
[$park, $isHistorical] = \App\Models\Park::getBySlug($slug);
|
||||
|
||||
if (!$park) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If using a historical slug, redirect to the current slug
|
||||
if ($isHistorical) {
|
||||
return redirect()->route('parks.show', ['slug' => $park->slug]);
|
||||
}
|
||||
|
||||
return view('parks.show', ['park' => $park]);
|
||||
})->name('parks.show');
|
||||
Route::get('/parks/{park}/edit', function () {
|
||||
return 'Edit Park';
|
||||
})->name('parks.edit');
|
||||
|
||||
// Photo routes
|
||||
Route::prefix('parks/{park}/photos')->name('parks.photos.')->group(function () {
|
||||
Route::get('/', [\App\Http\Controllers\PhotoController::class, 'index'])->name('index');
|
||||
Route::post('/', [\App\Http\Controllers\PhotoController::class, 'store'])->name('store');
|
||||
Route::put('/reorder', [\App\Http\Controllers\PhotoController::class, 'reorder'])->name('reorder');
|
||||
Route::get('/{photo}', [\App\Http\Controllers\PhotoController::class, 'show'])->name('show');
|
||||
Route::put('/{photo}', [\App\Http\Controllers\PhotoController::class, 'update'])->name('update');
|
||||
Route::delete('/{photo}', [\App\Http\Controllers\PhotoController::class, 'destroy'])->name('destroy');
|
||||
Route::put('/{photo}/featured', [\App\Http\Controllers\PhotoController::class, 'setFeatured'])->name('featured');
|
||||
});
|
||||
|
||||
// Rides routes
|
||||
Route::get('/rides', function () {
|
||||
return 'Rides Index';
|
||||
|
||||
153
tests/Unit/ParkModelTest.php
Normal file
153
tests/Unit/ParkModelTest.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Models\Park;
|
||||
use App\Models\Photo;
|
||||
use App\Models\SlugHistory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ParkModelTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/** @test */
|
||||
public function it_can_add_photos()
|
||||
{
|
||||
$park = Park::factory()->create();
|
||||
|
||||
$photo = $park->addPhoto([
|
||||
'title' => 'Test Photo',
|
||||
'file_path' => 'parks/test.jpg',
|
||||
'file_name' => 'test.jpg',
|
||||
]);
|
||||
|
||||
$this->assertInstanceOf(Photo::class, $photo);
|
||||
$this->assertEquals('Test Photo', $photo->title);
|
||||
$this->assertEquals(1, $park->photos()->count());
|
||||
$this->assertTrue($photo->is_featured);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_set_featured_photo()
|
||||
{
|
||||
$park = Park::factory()->create();
|
||||
|
||||
$photo1 = $park->addPhoto([
|
||||
'title' => 'Photo 1',
|
||||
'file_path' => 'parks/photo1.jpg',
|
||||
'file_name' => 'photo1.jpg',
|
||||
]);
|
||||
|
||||
$photo2 = $park->addPhoto([
|
||||
'title' => 'Photo 2',
|
||||
'file_path' => 'parks/photo2.jpg',
|
||||
'file_name' => 'photo2.jpg',
|
||||
]);
|
||||
|
||||
$this->assertTrue($photo1->is_featured);
|
||||
$this->assertFalse($photo2->is_featured);
|
||||
|
||||
$park->setFeaturedPhoto($photo2);
|
||||
|
||||
$photo1->refresh();
|
||||
$photo2->refresh();
|
||||
|
||||
$this->assertFalse($photo1->is_featured);
|
||||
$this->assertTrue($photo2->is_featured);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_reorder_photos()
|
||||
{
|
||||
$park = Park::factory()->create();
|
||||
|
||||
$photo1 = $park->addPhoto([
|
||||
'title' => 'Photo 1',
|
||||
'file_path' => 'parks/photo1.jpg',
|
||||
'file_name' => 'photo1.jpg',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$photo2 = $park->addPhoto([
|
||||
'title' => 'Photo 2',
|
||||
'file_path' => 'parks/photo2.jpg',
|
||||
'file_name' => 'photo2.jpg',
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$photo3 = $park->addPhoto([
|
||||
'title' => 'Photo 3',
|
||||
'file_path' => 'parks/photo3.jpg',
|
||||
'file_name' => 'photo3.jpg',
|
||||
'position' => 3,
|
||||
]);
|
||||
|
||||
// Reorder: 3, 1, 2
|
||||
$park->reorderPhotos([$photo3->id, $photo1->id, $photo2->id]);
|
||||
|
||||
$photo1->refresh();
|
||||
$photo2->refresh();
|
||||
$photo3->refresh();
|
||||
|
||||
$this->assertEquals(1, $photo3->position);
|
||||
$this->assertEquals(2, $photo1->position);
|
||||
$this->assertEquals(3, $photo2->position);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_by_slug()
|
||||
{
|
||||
$park = Park::factory()->create([
|
||||
'name' => 'Test Park',
|
||||
'slug' => 'test-park',
|
||||
]);
|
||||
|
||||
// Test current slug
|
||||
[$foundPark, $isHistorical] = Park::getBySlug('test-park');
|
||||
|
||||
$this->assertNotNull($foundPark);
|
||||
$this->assertEquals($park->id, $foundPark->id);
|
||||
$this->assertFalse($isHistorical);
|
||||
|
||||
// Change slug and test historical slug
|
||||
$park->slug = 'new-test-park';
|
||||
$park->save();
|
||||
|
||||
[$foundPark, $isHistorical] = Park::getBySlug('test-park');
|
||||
|
||||
$this->assertNotNull($foundPark);
|
||||
$this->assertEquals($park->id, $foundPark->id);
|
||||
$this->assertTrue($isHistorical);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_returns_absolute_url()
|
||||
{
|
||||
$park = Park::factory()->create([
|
||||
'slug' => 'test-park',
|
||||
]);
|
||||
|
||||
$this->assertEquals(route('parks.show', ['slug' => 'test-park']), $park->getAbsoluteUrl());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_returns_formatted_location()
|
||||
{
|
||||
$park = Park::factory()->create();
|
||||
|
||||
// Without location
|
||||
$this->assertEquals('', $park->formatted_location);
|
||||
|
||||
// With location
|
||||
$location = $park->updateLocation([
|
||||
'address' => '123 Main St',
|
||||
'city' => 'Orlando',
|
||||
'state' => 'FL',
|
||||
'country' => 'USA',
|
||||
]);
|
||||
|
||||
$this->assertEquals('123 Main St, Orlando, FL, USA', $location->formatted_address);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user