Refactor Park model, update routes for parks management, and enhance database migrations for park areas and locations

This commit is contained in:
pacnpal
2025-02-25 14:17:13 -05:00
parent 45f9e45b9a
commit 15b2d4ebcf
12 changed files with 301 additions and 151 deletions

View File

@@ -6,7 +6,7 @@ use App\Models\Park;
use App\Enums\ParkStatus;
use Livewire\Component;
use Livewire\WithPagination;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Builder;
class ParkListComponent extends Component
{
@@ -17,6 +17,7 @@ class ParkListComponent extends Component
public string $sort = 'name';
public string $direction = 'asc';
public ?string $operator = null;
public string $viewMode = 'grid';
/** @var array<string, string> */
public array $sortOptions = [
@@ -33,6 +34,7 @@ class ParkListComponent extends Component
'sort' => ['except' => 'name'],
'direction' => ['except' => 'asc'],
'operator' => ['except' => ''],
'viewMode' => ['except' => 'grid'],
];
public function mount(): void
@@ -55,6 +57,11 @@ class ParkListComponent extends Component
$this->resetPage('parks-page');
}
public function updatedViewMode(): void
{
// No need to reset page when changing view mode
}
public function sortBy(string $field): void
{
if ($this->sort === $field) {
@@ -84,10 +91,12 @@ class ParkListComponent extends Component
public function render()
{
$query = Park::query()
->with(['operator'])
->with(['operator', 'location'])
->when($this->search, function (Builder $query) {
$query->where('name', 'like', '%' . $this->search . '%')
->orWhere('description', 'like', '%' . $this->search . '%');
$query->where(function (Builder $q) {
$q->where('name', 'like', '%' . $this->search . '%')
->orWhere('description', 'like', '%' . $this->search . '%');
});
})
->when($this->status, function (Builder $query) {
$query->where('status', $this->status);
@@ -119,6 +128,7 @@ class ParkListComponent extends Component
'parks' => $query->paginate(12, pageName: 'parks-page'),
'statusOptions' => $this->getStatusOptions(),
'operatorOptions' => $this->getOperatorOptions(),
'viewMode' => $this->viewMode,
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ParkStatus;
use App\Traits\HasLocation;
use App\Traits\HasSlugHistory;
use App\Traits\HasParkStatistics;
use Illuminate\Database\Eloquent\Model;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
class Park extends Model
{
use HasFactory, HasSlugHistory, HasParkStatistics;
use HasFactory, HasSlugHistory, HasParkStatistics, HasLocation;
/**
* The attributes that are mass assignable.

View File

@@ -3,6 +3,7 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
@@ -11,6 +12,9 @@ return new class extends Migration
*/
public function up(): void
{
// Enable PostGIS extension if not enabled
DB::statement('CREATE EXTENSION IF NOT EXISTS postgis');
Schema::create('locations', function (Blueprint $table) {
$table->id();
@@ -24,9 +28,6 @@ return new class extends Migration
$table->string('country');
$table->string('postal_code')->nullable();
// Enable PostGIS extension if not enabled
DB::statement('CREATE EXTENSION IF NOT EXISTS postgis');
// Location name and type
$table->string('name')->nullable();
$table->string('location_type', 50)->nullable();
@@ -36,7 +37,7 @@ return new class extends Migration
$table->decimal('longitude', 9, 6)->nullable();
// Coordinates using PostGIS
$table->point('coordinates')->spatialIndex();
// We'll add the coordinates column after the table is created
$table->decimal('elevation', 8, 2)->nullable();
// Additional details
@@ -58,6 +59,10 @@ return new class extends Migration
$table->index('name');
$table->index('location_type');
});
// Add the coordinates column using PostGIS
DB::statement('ALTER TABLE locations ADD COLUMN coordinates GEOMETRY(Point, 4326)');
DB::statement('CREATE INDEX locations_coordinates_idx ON locations USING GIST (coordinates)');
}
/**

View File

@@ -18,15 +18,14 @@ return new class extends Migration
$table->integer('closed_areas')->default(0);
// Ride statistics
$table->integer('total_rides')->default(0);
$table->integer('total_coasters')->default(0);
// Note: ride_count and coaster_count already exist in the parks table
$table->integer('total_flat_rides')->default(0);
$table->integer('total_water_rides')->default(0);
// Visitor statistics
$table->integer('total_daily_capacity')->default(0);
$table->integer('average_wait_time')->nullable();
$table->decimal('average_rating', 3, 2)->nullable();
// Note: average_rating already exists in the parks table
// Historical data
$table->integer('total_rides_operated')->default(0);
@@ -40,8 +39,8 @@ return new class extends Migration
$table->decimal('guest_satisfaction', 3, 2)->nullable();
// Add indexes for common queries
$table->index(['operator_id', 'total_rides']);
$table->index(['operator_id', 'total_coasters']);
$table->index(['operator_id', 'ride_count']);
$table->index(['operator_id', 'coaster_count']);
$table->index(['operator_id', 'average_rating']);
});
}
@@ -52,21 +51,18 @@ return new class extends Migration
public function down(): void
{
Schema::table('parks', function (Blueprint $table) {
$table->dropIndex(['operator_id', 'total_rides']);
$table->dropIndex(['operator_id', 'total_coasters']);
$table->dropIndex(['operator_id', 'ride_count']);
$table->dropIndex(['operator_id', 'coaster_count']);
$table->dropIndex(['operator_id', 'average_rating']);
$table->dropColumn([
'total_areas',
'operating_areas',
'closed_areas',
'total_rides',
'total_coasters',
'total_flat_rides',
'total_water_rides',
'total_daily_capacity',
'average_wait_time',
'average_rating',
'total_rides_operated',
'total_rides_retired',
'last_expansion_date',

View File

@@ -31,6 +31,14 @@ Migrating the design from Django to Laravel implementation
- Tracked asset organization
- Maintained migration progress
5. Parks List Component
- Implemented ParkListComponent matching Django design
- Added grid/list view toggle functionality
- Implemented filtering and sorting controls
- Created responsive card layout for parks
- Added location display to park cards
- Ensured visual parity with Django implementation
### Current State
- Base layout template is ready
- Core styling system is in place
@@ -41,10 +49,11 @@ Migrating the design from Django to Laravel implementation
- Mobile menu
- User menu
- Auth menu
- Park list with filtering and view modes
### Next Steps
1. Component Migration
- Start with high-priority components (forms, modals, cards)
- Continue with remaining components (forms, modals, cards)
- Convert Django partials to Blade components
- Implement Livewire interactive components
- Test component functionality

View File

@@ -59,12 +59,20 @@ Features:
- Size
3. Display Features
- Responsive grid layout
- Responsive grid/list layout with toggle
- Status badges with colors
- Key statistics display
- Quick access to edit/view
- Website links
- Operator information
- Location information display
4. Django Parity Implementation
- Matches the original Django implementation's UI/UX
- Uses the same filter controls and layout
- Implements identical view mode toggle (grid/list)
- Displays the same park information cards
- Maintains consistent styling with the original
### 3. ParkAreaFormComponent
Located in `app/Livewire/ParkAreaFormComponent.php`

2
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "laravel",
"name": "thrillwiki_laravel",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title ?? 'ThrillWiki' }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</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">
<div class="flex items-center">
<a href="{{ route('home') }}" class="text-xl font-bold text-indigo-600 dark:text-indigo-400">
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')
@auth
@livewire('user-menu-component')
@else
@livewire('auth-menu-component')
@endauth
@livewire('mobile-menu-component')
</div>
</div>
</div>
</header>
<main>
{{ $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">
&copy; {{ date('Y') }} ThrillWiki. All rights reserved.
</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>
</div>
</div>
</footer>
@livewireScripts
</body>
</html>

View File

@@ -1,136 +1,186 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Filters and Search -->
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-4 mb-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Search -->
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
<div class="mt-1">
<input type="text" wire:model.live.debounce.300ms="search" id="search"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
placeholder="Search parks...">
</div>
</div>
<!-- Status Filter -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
<div class="mt-1">
<select wire:model.live="status" id="status"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<!-- Operator Filter -->
<div>
<label for="operator" class="block text-sm font-medium text-gray-700">Operator</label>
<div class="mt-1">
<select wire:model.live="operator" id="operator"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach($operatorOptions as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
</div>
</div>
<!-- Sort -->
<div>
<label for="sort" class="block text-sm font-medium text-gray-700">Sort By</label>
<div class="mt-1">
<select wire:model.live="sort" id="sort"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach($sortOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
<button wire:click="$set('viewMode', 'grid')"
class="p-2 rounded transition-colors duration-200 {{ $viewMode == 'grid' ? 'bg-white shadow-sm' : '' }}"
aria-label="Grid view"
aria-pressed="{{ $viewMode == 'grid' ? 'true' : 'false' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
</button>
<button wire:click="$set('viewMode', 'list')"
class="p-2 rounded transition-colors duration-200 {{ $viewMode == 'list' ? 'bg-white shadow-sm' : '' }}"
aria-label="List view"
aria-pressed="{{ $viewMode == 'list' ? 'true' : 'false' }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h7"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Parks Grid -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@forelse($parks as $park)
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl overflow-hidden">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">
<a href="{{ route('parks.show', $park) }}" class="hover:text-indigo-600">
{{ $park->name }}
</a>
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $park->status_classes }}">
{{ $park->status->label() }}
</span>
</div>
<div class="text-sm text-gray-500 mb-4">
{{ $park->brief_description }}
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Operator:</span>
<span class="text-gray-900">{{ $park->operator?->name ?? 'Unknown' }}</span>
</div>
@if($park->opening_year)
<div>
<span class="text-gray-500">Opened:</span>
<span class="text-gray-900">{{ $park->opening_year }}</span>
</div>
@endif
@if($park->size_acres)
<div>
<span class="text-gray-500">Size:</span>
<span class="text-gray-900">{{ $park->size_display }}</span>
</div>
@endif
<div>
<span class="text-gray-500">Rides:</span>
<span class="text-gray-900">{{ $park->ride_count ?? 0 }}</span>
</div>
</div>
<div class="mt-4 flex justify-end space-x-3">
@if($park->website)
<a href="{{ $park->website_url }}" target="_blank" rel="noopener"
class="text-sm text-gray-500 hover:text-gray-900">
Visit Website
</a>
@endif
<a href="{{ route('parks.edit', $park) }}"
class="text-sm text-indigo-600 hover:text-indigo-900">
Edit
</a>
</div>
</div>
</div>
@empty
<div class="col-span-full text-center py-12">
<h3 class="text-lg font-medium text-gray-900">No parks found</h3>
<p class="mt-2 text-sm text-gray-500">Try adjusting your filters or search terms.</p>
</div>
@endforelse
</div>
<!-- Pagination -->
<div class="mt-6">
{{ $parks->links() }}
</div>
<!-- Create Button -->
<div class="fixed bottom-6 right-6">
<a href="{{ route('parks.create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<a href="{{ route('parks.create') }}"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
data-testid="add-park-button">
<svg class="-ml-1 mr-2 h-5 w-5" 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="M12 4v16m8-8H4" />
</svg>
Add Park
</a>
</div>
<!-- Search and Filters -->
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<label for="search" class="sr-only">Search parks</label>
<input type="search"
wire:model.live.debounce.300ms="search"
id="search"
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
placeholder="Search parks by name or location..."
aria-label="Search parks">
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<div wire:loading wire:target="search">
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Status Filter -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
<div class="mt-1">
<select wire:model.live="status" id="status"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<!-- Operator Filter -->
<div>
<label for="operator" class="block text-sm font-medium text-gray-700">Operator</label>
<div class="mt-1">
<select wire:model.live="operator" id="operator"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach($operatorOptions as $id => $name)
<option value="{{ $id }}">{{ $name }}</option>
@endforeach
</select>
</div>
</div>
<!-- Sort -->
<div>
<label for="sort" class="block text-sm font-medium text-gray-700">Sort By</label>
<div class="mt-1">
<select wire:model.live="sort" id="sort"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
@foreach($sortOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Parks Grid/List -->
<div id="park-results"
class="bg-white rounded-lg shadow"
data-view-mode="{{ $viewMode }}">
<div class="{{ $viewMode == 'grid' ? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-4' : 'flex flex-col gap-4 p-4' }}"
data-testid="park-list">
@forelse($parks as $park)
<article class="park-card group relative bg-white border rounded-lg transition-all duration-200 ease-in-out hover:shadow-lg {{ $viewMode == 'list' ? 'flex gap-4 p-4' : '' }}"
data-testid="park-card"
data-park-id="{{ $park->id }}"
data-view-mode="{{ $viewMode }}">
<a href="{{ route('parks.show', $park) }}"
class="absolute inset-0 z-0"
aria-label="View details for {{ $park->name }}"></a>
<div class="relative z-10 {{ $viewMode == 'grid' ? 'aspect-video' : '' }}">
<div class="{{ $viewMode == 'grid' ? 'w-full h-full bg-gray-100 rounded-t-lg flex items-center justify-center' : 'w-24 h-24 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center' }}"
role="img"
aria-label="Park initial letter">
<span class="text-2xl font-medium text-gray-400">{{ substr($park->name, 0, 1) }}</span>
</div>
</div>
<div class="{{ $viewMode == 'grid' ? 'p-4' : 'flex-1 min-w-0' }}">
<h3 class="text-lg font-semibold text-gray-900 truncate group-hover:text-blue-600">
{{ $park->name }}
</h3>
<div class="mt-1 text-sm text-gray-500 truncate">
@if($park->location)
{{ $park->location->city }}{{ $park->location->state ? ', ' . $park->location->state : '' }}{{ $park->location->country ? ', ' . $park->location->country : '' }}
@else
Location unknown
@endif
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $park->status_classes }}"
data-testid="park-status">
{{ $park->status->label() }}
</span>
@if($park->opening_date)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
data-testid="park-opening-date">
Opened {{ date('Y', strtotime($park->opening_date)) }}
</span>
@endif
@if($park->ride_count)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
data-testid="park-ride-count">
{{ $park->ride_count }} ride{{ $park->ride_count != 1 ? 's' : '' }}
</span>
@endif
@if($park->coaster_count)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
data-testid="park-coaster-count">
{{ $park->coaster_count }} coaster{{ $park->coaster_count != 1 ? 's' : '' }}
</span>
@endif
</div>
</div>
</article>
@empty
<div class="{{ $viewMode == 'grid' ? 'col-span-full' : '' }} p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
@if($search)
No parks found matching "{{ $search }}". Try adjusting your search terms.
@else
No parks found matching your criteria. Try adjusting your filters.
@endif
<a href="{{ route('parks.create') }}" class="text-blue-600 hover:underline">Add a new park</a>.
</div>
@endforelse
</div>
</div>
<!-- Pagination -->
<div class="mt-6">
{{ $parks->links() }}
</div>
</div>

View File

@@ -10,9 +10,16 @@ Route::get('/', function () {
Route::get('/counter', Counter::class);
// Parks routes
Route::get('/parks', function () {
return 'Parks Index';
})->name('parks.index');
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';
})->name('parks.show');
Route::get('/parks/{park}/edit', function () {
return 'Edit Park';
})->name('parks.edit');
// Rides routes
Route::get('/rides', function () {