mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 05:51: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 App\Enums\ParkStatus;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class ParkListComponent extends Component
|
class ParkListComponent extends Component
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,7 @@ class ParkListComponent extends Component
|
|||||||
public string $sort = 'name';
|
public string $sort = 'name';
|
||||||
public string $direction = 'asc';
|
public string $direction = 'asc';
|
||||||
public ?string $operator = null;
|
public ?string $operator = null;
|
||||||
|
public string $viewMode = 'grid';
|
||||||
|
|
||||||
/** @var array<string, string> */
|
/** @var array<string, string> */
|
||||||
public array $sortOptions = [
|
public array $sortOptions = [
|
||||||
@@ -33,6 +34,7 @@ class ParkListComponent extends Component
|
|||||||
'sort' => ['except' => 'name'],
|
'sort' => ['except' => 'name'],
|
||||||
'direction' => ['except' => 'asc'],
|
'direction' => ['except' => 'asc'],
|
||||||
'operator' => ['except' => ''],
|
'operator' => ['except' => ''],
|
||||||
|
'viewMode' => ['except' => 'grid'],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
@@ -55,6 +57,11 @@ class ParkListComponent extends Component
|
|||||||
$this->resetPage('parks-page');
|
$this->resetPage('parks-page');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedViewMode(): void
|
||||||
|
{
|
||||||
|
// No need to reset page when changing view mode
|
||||||
|
}
|
||||||
|
|
||||||
public function sortBy(string $field): void
|
public function sortBy(string $field): void
|
||||||
{
|
{
|
||||||
if ($this->sort === $field) {
|
if ($this->sort === $field) {
|
||||||
@@ -84,10 +91,12 @@ class ParkListComponent extends Component
|
|||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
$query = Park::query()
|
$query = Park::query()
|
||||||
->with(['operator'])
|
->with(['operator', 'location'])
|
||||||
->when($this->search, function (Builder $query) {
|
->when($this->search, function (Builder $query) {
|
||||||
$query->where('name', 'like', '%' . $this->search . '%')
|
$query->where(function (Builder $q) {
|
||||||
->orWhere('description', 'like', '%' . $this->search . '%');
|
$q->where('name', 'like', '%' . $this->search . '%')
|
||||||
|
->orWhere('description', 'like', '%' . $this->search . '%');
|
||||||
|
});
|
||||||
})
|
})
|
||||||
->when($this->status, function (Builder $query) {
|
->when($this->status, function (Builder $query) {
|
||||||
$query->where('status', $this->status);
|
$query->where('status', $this->status);
|
||||||
@@ -119,6 +128,7 @@ class ParkListComponent extends Component
|
|||||||
'parks' => $query->paginate(12, pageName: 'parks-page'),
|
'parks' => $query->paginate(12, pageName: 'parks-page'),
|
||||||
'statusOptions' => $this->getStatusOptions(),
|
'statusOptions' => $this->getStatusOptions(),
|
||||||
'operatorOptions' => $this->getOperatorOptions(),
|
'operatorOptions' => $this->getOperatorOptions(),
|
||||||
|
'viewMode' => $this->viewMode,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Enums\ParkStatus;
|
use App\Enums\ParkStatus;
|
||||||
|
use App\Traits\HasLocation;
|
||||||
use App\Traits\HasSlugHistory;
|
use App\Traits\HasSlugHistory;
|
||||||
use App\Traits\HasParkStatistics;
|
use App\Traits\HasParkStatistics;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
|
|
||||||
class Park extends Model
|
class Park extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, HasSlugHistory, HasParkStatistics;
|
use HasFactory, HasSlugHistory, HasParkStatistics, HasLocation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
{
|
{
|
||||||
@@ -11,6 +12,9 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
|
// Enable PostGIS extension if not enabled
|
||||||
|
DB::statement('CREATE EXTENSION IF NOT EXISTS postgis');
|
||||||
|
|
||||||
Schema::create('locations', function (Blueprint $table) {
|
Schema::create('locations', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
|
|
||||||
@@ -24,9 +28,6 @@ return new class extends Migration
|
|||||||
$table->string('country');
|
$table->string('country');
|
||||||
$table->string('postal_code')->nullable();
|
$table->string('postal_code')->nullable();
|
||||||
|
|
||||||
// Enable PostGIS extension if not enabled
|
|
||||||
DB::statement('CREATE EXTENSION IF NOT EXISTS postgis');
|
|
||||||
|
|
||||||
// Location name and type
|
// Location name and type
|
||||||
$table->string('name')->nullable();
|
$table->string('name')->nullable();
|
||||||
$table->string('location_type', 50)->nullable();
|
$table->string('location_type', 50)->nullable();
|
||||||
@@ -36,7 +37,7 @@ return new class extends Migration
|
|||||||
$table->decimal('longitude', 9, 6)->nullable();
|
$table->decimal('longitude', 9, 6)->nullable();
|
||||||
|
|
||||||
// Coordinates using PostGIS
|
// Coordinates using PostGIS
|
||||||
$table->point('coordinates')->spatialIndex();
|
// We'll add the coordinates column after the table is created
|
||||||
$table->decimal('elevation', 8, 2)->nullable();
|
$table->decimal('elevation', 8, 2)->nullable();
|
||||||
|
|
||||||
// Additional details
|
// Additional details
|
||||||
@@ -58,6 +59,10 @@ return new class extends Migration
|
|||||||
$table->index('name');
|
$table->index('name');
|
||||||
$table->index('location_type');
|
$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);
|
$table->integer('closed_areas')->default(0);
|
||||||
|
|
||||||
// Ride statistics
|
// Ride statistics
|
||||||
$table->integer('total_rides')->default(0);
|
// Note: ride_count and coaster_count already exist in the parks table
|
||||||
$table->integer('total_coasters')->default(0);
|
|
||||||
$table->integer('total_flat_rides')->default(0);
|
$table->integer('total_flat_rides')->default(0);
|
||||||
$table->integer('total_water_rides')->default(0);
|
$table->integer('total_water_rides')->default(0);
|
||||||
|
|
||||||
// Visitor statistics
|
// Visitor statistics
|
||||||
$table->integer('total_daily_capacity')->default(0);
|
$table->integer('total_daily_capacity')->default(0);
|
||||||
$table->integer('average_wait_time')->nullable();
|
$table->integer('average_wait_time')->nullable();
|
||||||
$table->decimal('average_rating', 3, 2)->nullable();
|
// Note: average_rating already exists in the parks table
|
||||||
|
|
||||||
// Historical data
|
// Historical data
|
||||||
$table->integer('total_rides_operated')->default(0);
|
$table->integer('total_rides_operated')->default(0);
|
||||||
@@ -40,8 +39,8 @@ return new class extends Migration
|
|||||||
$table->decimal('guest_satisfaction', 3, 2)->nullable();
|
$table->decimal('guest_satisfaction', 3, 2)->nullable();
|
||||||
|
|
||||||
// Add indexes for common queries
|
// Add indexes for common queries
|
||||||
$table->index(['operator_id', 'total_rides']);
|
$table->index(['operator_id', 'ride_count']);
|
||||||
$table->index(['operator_id', 'total_coasters']);
|
$table->index(['operator_id', 'coaster_count']);
|
||||||
$table->index(['operator_id', 'average_rating']);
|
$table->index(['operator_id', 'average_rating']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -52,21 +51,18 @@ return new class extends Migration
|
|||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::table('parks', function (Blueprint $table) {
|
Schema::table('parks', function (Blueprint $table) {
|
||||||
$table->dropIndex(['operator_id', 'total_rides']);
|
$table->dropIndex(['operator_id', 'ride_count']);
|
||||||
$table->dropIndex(['operator_id', 'total_coasters']);
|
$table->dropIndex(['operator_id', 'coaster_count']);
|
||||||
$table->dropIndex(['operator_id', 'average_rating']);
|
$table->dropIndex(['operator_id', 'average_rating']);
|
||||||
|
|
||||||
$table->dropColumn([
|
$table->dropColumn([
|
||||||
'total_areas',
|
'total_areas',
|
||||||
'operating_areas',
|
'operating_areas',
|
||||||
'closed_areas',
|
'closed_areas',
|
||||||
'total_rides',
|
|
||||||
'total_coasters',
|
|
||||||
'total_flat_rides',
|
'total_flat_rides',
|
||||||
'total_water_rides',
|
'total_water_rides',
|
||||||
'total_daily_capacity',
|
'total_daily_capacity',
|
||||||
'average_wait_time',
|
'average_wait_time',
|
||||||
'average_rating',
|
|
||||||
'total_rides_operated',
|
'total_rides_operated',
|
||||||
'total_rides_retired',
|
'total_rides_retired',
|
||||||
'last_expansion_date',
|
'last_expansion_date',
|
||||||
@@ -31,6 +31,14 @@ Migrating the design from Django to Laravel implementation
|
|||||||
- Tracked asset organization
|
- Tracked asset organization
|
||||||
- Maintained migration progress
|
- 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
|
### Current State
|
||||||
- Base layout template is ready
|
- Base layout template is ready
|
||||||
- Core styling system is in place
|
- Core styling system is in place
|
||||||
@@ -41,10 +49,11 @@ Migrating the design from Django to Laravel implementation
|
|||||||
- Mobile menu
|
- Mobile menu
|
||||||
- User menu
|
- User menu
|
||||||
- Auth menu
|
- Auth menu
|
||||||
|
- Park list with filtering and view modes
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
1. Component Migration
|
1. Component Migration
|
||||||
- Start with high-priority components (forms, modals, cards)
|
- Continue with remaining components (forms, modals, cards)
|
||||||
- Convert Django partials to Blade components
|
- Convert Django partials to Blade components
|
||||||
- Implement Livewire interactive components
|
- Implement Livewire interactive components
|
||||||
- Test component functionality
|
- Test component functionality
|
||||||
|
|||||||
@@ -59,12 +59,20 @@ Features:
|
|||||||
- Size
|
- Size
|
||||||
|
|
||||||
3. Display Features
|
3. Display Features
|
||||||
- Responsive grid layout
|
- Responsive grid/list layout with toggle
|
||||||
- Status badges with colors
|
- Status badges with colors
|
||||||
- Key statistics display
|
- Key statistics display
|
||||||
- Quick access to edit/view
|
- Quick access to edit/view
|
||||||
- Website links
|
- Website links
|
||||||
- Operator information
|
- 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
|
### 3. ParkAreaFormComponent
|
||||||
Located in `app/Livewire/ParkAreaFormComponent.php`
|
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,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"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">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<!-- Filters and Search -->
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-4 mb-6">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
|
||||||
<!-- 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 class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
|
||||||
<div>
|
<button wire:click="$set('viewMode', 'grid')"
|
||||||
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
|
class="p-2 rounded transition-colors duration-200 {{ $viewMode == 'grid' ? 'bg-white shadow-sm' : '' }}"
|
||||||
<div class="mt-1">
|
aria-label="Grid view"
|
||||||
<select wire:model.live="status" id="status"
|
aria-pressed="{{ $viewMode == 'grid' ? 'true' : 'false' }}">
|
||||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@foreach($statusOptions as $value => $label)
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
<option value="{{ $value }}">{{ $label }}</option>
|
</svg>
|
||||||
@endforeach
|
</button>
|
||||||
</select>
|
<button wire:click="$set('viewMode', 'list')"
|
||||||
</div>
|
class="p-2 rounded transition-colors duration-200 {{ $viewMode == 'list' ? 'bg-white shadow-sm' : '' }}"
|
||||||
</div>
|
aria-label="List view"
|
||||||
|
aria-pressed="{{ $viewMode == 'list' ? 'true' : 'false' }}">
|
||||||
<!-- Operator Filter -->
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h7"/>
|
||||||
<label for="operator" class="block text-sm font-medium text-gray-700">Operator</label>
|
</svg>
|
||||||
<div class="mt-1">
|
</button>
|
||||||
<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 -->
|
|
||||||
<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') }}"
|
<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">
|
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">
|
<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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Add Park
|
Add Park
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@@ -10,9 +10,16 @@ Route::get('/', function () {
|
|||||||
Route::get('/counter', Counter::class);
|
Route::get('/counter', Counter::class);
|
||||||
|
|
||||||
// Parks routes
|
// Parks routes
|
||||||
Route::get('/parks', function () {
|
Route::get('/parks', \App\Livewire\ParkListComponent::class)->name('parks.index');
|
||||||
return 'Parks Index';
|
Route::get('/parks/create', function () {
|
||||||
})->name('parks.index');
|
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
|
// Rides routes
|
||||||
Route::get('/rides', function () {
|
Route::get('/rides', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user