mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 02:31:09 -05:00
Refactor Park model, update routes for parks management, and enhance database migrations for park areas and locations
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
@@ -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
|
||||
|
||||
@@ -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
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "laravel",
|
||||
"name": "thrillwiki_laravel",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
64
resources/views/components/layouts/app.blade.php
Normal file
64
resources/views/components/layouts/app.blade.php
Normal 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">
|
||||
© {{ 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>
|
||||
@@ -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>
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user