first commit

This commit is contained in:
Doar Mihai
2026-04-28 16:10:27 +03:00
commit b73f568d90
107 changed files with 18067 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+65
View File
@@ -0,0 +1,65 @@
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=wiki_travel_site
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+25
View File
@@ -0,0 +1,25 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+529
View File
@@ -0,0 +1,529 @@
### Project Idea
This is a travel app based on wikipedia informations.
The user can choose a city, we show the city on the map with some info about it (from wikipedia, like city summary) and show things to do there.
We need to store in mysql data from wikipedia, opentripmap and overpass for cache.
---
## Technical Architecture
### Tech Stack
**Frontend:**
- Alpine.js for interactive UI components
- Tailwind CSS for styling (or Bootstrap 5)
- Leaflet.js for interactive maps
- Laravel Livewire (optional) for dynamic components
- Blade templating engine
**Backend:**
- Laravel 10/11 (PHP)
- MySQL database for caching API responses
- Redis for caching and queue management
- Laravel HTTP Client (Guzzle) for API requests
- Laravel Cache for API response caching
- Laravel Jobs/Queues for background API fetching
**External APIs:**
- Wikipedia API - City summaries and information
- OpenTripMap API - Points of interest and attractions
- Overpass API (OpenStreetMap) - Geographic data and additional POIs
---
## Database Schema
### Migrations
**cities** (create_cities_table.php)
```php
$table->id();
$table->string('name');
$table->string('country');
$table->decimal('latitude', 10, 7);
$table->decimal('longitude', 10, 7);
$table->text('wikipedia_summary')->nullable();
$table->string('wikipedia_url')->nullable();
$table->string('wikipedia_page_id')->nullable();
$table->timestamps();
$table->timestamp('wikipedia_cached_at')->nullable();
// Indexes
$table->index(['name', 'country']);
$table->index('wikipedia_cached_at');
```
**attractions** (create_attractions_table.php)
```php
$table->id();
$table->foreignId('city_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->decimal('latitude', 10, 7);
$table->decimal('longitude', 10, 7);
$table->string('category')->nullable();
$table->enum('source', ['opentripmap', 'overpass', 'wikipedia']);
$table->string('source_id')->nullable();
$table->decimal('rating', 3, 2)->nullable();
$table->string('image_url')->nullable();
$table->timestamps();
$table->timestamp('cached_at')->nullable();
// Indexes
$table->index('city_id');
$table->index('category');
$table->index('cached_at');
```
**users** (Laravel's default users table with modifications)
```php
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
```
**favorites** (create_favorites_table.php)
```php
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('city_id')->constrained()->onDelete('cascade');
$table->timestamps();
// Indexes
$table->unique(['user_id', 'city_id']); // Prevent duplicate favorites
$table->index('user_id');
```
**trips** (create_trips_table.php)
```php
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
// Indexes
$table->index('user_id');
```
**city_trip** (create_city_trip_table.php) - Pivot table
```php
$table->id();
$table->foreignId('trip_id')->constrained()->onDelete('cascade');
$table->foreignId('city_id')->constrained()->onDelete('cascade');
$table->integer('order')->default(0); // Order of cities in trip
$table->timestamps();
// Indexes
$table->index(['trip_id', 'city_id']);
```
**personal_access_tokens** (Laravel Sanctum - auto-generated)
- Handles API tokens for mobile app authentication
**Note:** Laravel's built-in caching system (Redis/Database) will handle cache metadata, so a separate cache_metadata table is optional.
---
## Routes Structure
### Web Routes (resources/routes/web.php)
**GET /**
- Home page with city search
**GET /cities/{city}**
- City details page with map and attractions
- Uses Blade view with Alpine.js for interactivity
### API Routes (resources/routes/api.php)
**Authentication Routes (Laravel Sanctum)**
**POST /api/register**
- Register new user account
- Returns user data and API token
**POST /api/login**
- Login with email and password
- Returns user data and API token
**POST /api/logout** (Protected)
- Logout and revoke current token
- Requires: Bearer token
**GET /api/user** (Protected)
- Get authenticated user profile
- Requires: Bearer token
**Public API Routes**
**GET /api/cities/search?q={query}**
- Search for cities by name
- Returns JSON list of matching cities
- Public access (no auth required)
**GET /api/cities/{cityId}**
- Get detailed information about a specific city
- Returns city data from cache or fetches from Wikipedia API
- Public access
**GET /api/cities/{cityId}/attractions**
- Get all attractions for a specific city
- Returns cached data or fetches from OpenTripMap and Overpass APIs
- Optional query params: ?category={category}&limit={number}
- Public access
**GET /api/cities/{cityId}/map-data**
- Get geographic data for map rendering
- Returns coordinates and boundaries for Leaflet
- Public access
**Protected API Routes (Require Authentication)**
**POST /api/cities** (Protected)
- Add a new city to the database
- Dispatches job to fetch data from all APIs
- Requires: Bearer token
**GET /api/favorites** (Protected)
- Get user's favorite cities
- Returns list of saved cities
**POST /api/favorites/{cityId}** (Protected)
- Add city to user favorites
- Returns updated favorites list
**DELETE /api/favorites/{cityId}** (Protected)
- Remove city from user favorites
- Returns success status
**GET /api/trips** (Protected)
- Get user's saved trips/itineraries
- Returns list of trips with cities
**POST /api/trips** (Protected)
- Create new trip/itinerary
- Body: {name, description, city_ids[]}
**PUT /api/trips/{tripId}** (Protected)
- Update existing trip
- Body: {name, description, city_ids[]}
**DELETE /api/trips/{tripId}** (Protected)
- Delete trip
- Returns success status
---
## Core Features
### 1. City Search
- Autocomplete search functionality
- Search by city name
- Display search results with country and preview info
### 2. City Details Page
- Display city name and country
- Show Wikipedia summary (first 2-3 paragraphs)
- Link to full Wikipedia article
- Display city location on interactive map
### 3. Interactive Map
- Centered on selected city
- Markers for attractions/points of interest
- Color-coded markers by category
- Click markers to see attraction details
- Zoom and pan controls
### 4. Attractions List
- Categorized list of things to do
- Filter by category (museums, restaurants, parks, etc.)
- Sort by distance, rating, or name
- Click to highlight on map
### 5. Caching System
- Cache Wikipedia data for 30 days
- Cache OpenTripMap data for 7 days
- Cache Overpass data for 7 days
- Automatic cache invalidation and refresh
---
## Implementation Steps
### Phase 1: Laravel Project Setup
1. Initialize Laravel project: `composer create-project laravel/laravel wiki_travel_site`
2. Install Laravel Sanctum for API authentication: `composer require laravel/sanctum`
3. Publish Sanctum config: `php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"`
4. Install Tailwind CSS: `npm install -D tailwindcss postcss autoprefixer && npx tailwindcss init`
5. Configure MySQL database connection in `.env`
6. Set up Redis for caching
7. Create database migrations for cities, attractions, users, favorites, trips
8. Run migrations: `php artisan migrate`
9. Configure CORS for mobile app in `config/cors.php`
### Phase 2: Models & Services
1. Create Eloquent models: `City`, `Attraction`, `Trip`, `Favorite`
2. Define model relationships:
- City hasMany Attractions
- User hasMany Favorites
- User hasMany Trips
- Trip belongsToMany Cities (pivot: city_trip)
- City belongsToMany Trips
3. Add HasApiTokens trait to User model for Sanctum
4. Create service classes:
- `WikipediaService` - Fetch city data from Wikipedia API
- `OpenTripMapService` - Fetch attractions from
- OpenTripMap API
- `OverpassService` - Fetch geographic data from Overpass API
5. Implement Laravel Cache facade for API response caching
6. Create Jobs for background API fetching:
- `FetchCityDataJob`
- `FetchAttractionsJob`
### Phase 3: Authentication & Controllers
1. Create `AuthController` for registration/login/logout
2. Create `CityController` for web routes
3. Create API controllers:
- `API\CityController` - City and attraction endpoints
- `API\FavoriteController` - Favorite management
- `API\TripController` - Trip/itinerary management
- `API\UserController` - User profile management
4. Define web routes in `routes/web.php`
5. Define API routes in `routes/api.php` with Sanctum middleware
6. Implement API resource classes for JSON responses:
- `CityResource`
- `AttractionResource`
- `TripResource`
- `UserResource`
7. Create Form Request validators for registration and trip creation
### Phase 4: Frontend Views
1. Create Blade layouts:
- `layouts/app.blade.php` - Main layout with Tailwind/Bootstrap
- `welcome.blade.php` - Homepage with search
- `cities/show.blade.php` - City details page
2. Install and configure Alpine.js
3. Integrate Leaflet.js for maps
4. Create Alpine.js components:
- City search with autocomplete
- Interactive map with markers
- Attractions filter and list
5. Style with Tailwind CSS utility classes
### Phase 5: Integration & Caching
1. Implement cache logic in services (30 days for Wikipedia, 7 days for attractions)
2. Configure Redis cache driver in `config/cache.php`
3. Add error handling and logging
4. Create artisan commands:
- `php artisan cache:clear-cities` - Clear city cache
- `php artisan attractions:refresh {cityId}` - Refresh attraction data
5. Test all API integrations
### Phase 6: Polish & Optimization
1. Add loading states with Alpine.js
2. Implement responsive design (mobile-first with Tailwind)
3. Optimize database queries (eager loading, indexing)
4. Add pagination for attractions list
5. Implement search history using Laravel sessions
6. Write feature tests with PHPUnit
7. Deploy to production (Laravel Forge, Vapor, or custom server)
---
## Data Flow
1. User searches for a city
2. Frontend sends request to backend
3. Backend checks MySQL cache:
- If cached and not expired: return cached data
- If not cached or expired: fetch from Wikipedia API, store in cache, return data
4. User selects a city
5. Backend fetches attractions:
- Check cache for OpenTripMap and Overpass data
- If expired, fetch fresh data and update cache
- Merge data from multiple sources
6. Frontend displays city info on map with markers
7. User interacts with map and attraction list
---
## Environment Variables (.env)
```
APP_NAME="Wiki Travel Site"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=wiki_travel_site
DB_USERNAME=root
DB_PASSWORD=
# Redis Cache
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# API Keys
OPENTRIPMAP_API_KEY=your_key_here
WIKIPEDIA_API_URL=https://en.wikipedia.org/api/rest_v1
OVERPASS_API_URL=https://overpass-api.de/api/interpreter
# Cache Settings (in days)
CACHE_WIKIPEDIA_TTL=30
CACHE_ATTRACTIONS_TTL=7
```
---
## Laravel-Specific Features to Leverage
- **Eloquent ORM** - For database interactions and relationships
- **Laravel Cache** - Redis-backed caching for API responses
- **Laravel Jobs/Queues** - Background processing for API requests
- **Artisan Commands** - Custom commands for cache management
- **API Resources** - Transform Eloquent models to JSON
- **Form Requests** - Validate incoming city search requests
- **Laravel Mix/Vite** - Asset compilation for Tailwind CSS and Alpine.js
- **Blade Components** - Reusable UI components (search bar, attraction card, etc.)
- **Laravel Sanctum** - API authentication (for future mobile app)
---
## Project Structure
```
wiki_travel_site/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── CityController.php
│ │ │ ├── AuthController.php
│ │ │ └── API/
│ │ │ ├── CityController.php
│ │ │ ├── FavoriteController.php
│ │ │ ├── TripController.php
│ │ │ └── UserController.php
│ │ ├── Resources/
│ │ │ ├── CityResource.php
│ │ │ ├── AttractionResource.php
│ │ │ ├── TripResource.php
│ │ │ └── UserResource.php
│ │ ├── Requests/
│ │ │ ├── RegisterRequest.php
│ │ │ ├── LoginRequest.php
│ │ │ └── TripRequest.php
│ │ └── Middleware/
│ │ └── EnsureTokenIsValid.php
│ ├── Models/
│ │ ├── User.php (with HasApiTokens trait)
│ │ ├── City.php
│ │ ├── Attraction.php
│ │ ├── Trip.php
│ │ └── Favorite.php
│ ├── Services/
│ │ ├── WikipediaService.php
│ │ ├── OpenTripMapService.php
│ │ └── OverpassService.php
│ └── Jobs/
│ ├── FetchCityDataJob.php
│ └── FetchAttractionsJob.php
├── database/
│ └── migrations/
│ ├── create_users_table.php
│ ├── create_cities_table.php
│ ├── create_attractions_table.php
│ ├── create_favorites_table.php
│ ├── create_trips_table.php
│ ├── create_city_trip_table.php
│ └── create_personal_access_tokens_table.php (Sanctum)
├── resources/
│ ├── views/
│ │ ├── layouts/
│ │ │ └── app.blade.php
│ │ ├── auth/
│ │ │ ├── login.blade.php
│ │ │ └── register.blade.php
│ │ ├── welcome.blade.php
│ │ └── cities/
│ │ └── show.blade.php
│ ├── css/
│ │ └── app.css (Tailwind)
│ └── js/
│ └── app.js (Alpine.js)
├── routes/
│ ├── web.php
│ └── api.php (with Sanctum auth middleware)
└── config/
├── cache.php
├── sanctum.php
├── cors.php (configured for mobile app)
└── services.php (API keys)
```
---
## Mobile App Integration
The backend API is designed to support a mobile application using Laravel Sanctum for token-based authentication.
### Mobile App Authentication Flow
1. **Registration:** POST /api/register → Returns user + token
2. **Login:** POST /api/login → Returns user + token
3. **Authenticated Requests:** Include token in header: `Authorization: Bearer {token}`
4. **Logout:** POST /api/logout → Revokes token
### Mobile App Features
- Browse and search cities (no auth required)
- View city details and attractions on map
- User registration and login
- Save favorite cities
- Create and manage trip itineraries
- Sync favorites and trips across devices
- Offline caching (handle on mobile side)
### CORS Configuration
Configure `config/cors.php` to allow requests from mobile app:
```php
'paths' => ['api/*'],
'allowed_origins' => ['*'], // Or specify mobile app origins
'allowed_methods' => ['*'],
'allowed_headers' => ['*'],
'supports_credentials' => false,
```
---
## Future Enhancements
- Weather information integration
- User reviews and ratings with Laravel policies
- Photo galleries from Wikimedia Commons
- Nearby restaurants and hotels
- Public transportation information
- Push notifications for trip reminders (mobile app)
- Social sharing of trips
- Multi-language support with Laravel localization
- Laravel Scout for full-text search
- Telescope for debugging during development
- Rate limiting for API endpoints
- Email verification for user accounts
- Password reset functionality
- User profile pictures and customization
+58
View File
@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+6
View File
@@ -0,0 +1,6 @@
1. On main page, instead of the black boxes we should have as a background images from those cities.
2. on /cities/{citySlug} instead of the gradient we should display a picture from the city
3. the points on maps should have icons for each category
4. there is a bug, if I click on a attraction from the map and then zoom in or out the points remain fixed to the container, and are not on their respective locations anymore.
5. when i search a city for the first time there are no attractions, as a user searches for a city we should also fetch the attractions
6. auth
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
#[Signature('cache:clear-cities')]
#[Description('Clear all cached city and attraction data')]
class ClearCitiesCache extends Command
{
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('Clearing cities cache...');
try {
// Get the Redis connection used by cache
$redis = Cache::getStore()->getRedis();
$prefix = config('cache.prefix');
// Get all cache keys that match our patterns
$patterns = [
'wikipedia:*',
'opentripmap:*',
'overpass:*',
];
$clearedCount = 0;
foreach ($patterns as $pattern) {
$fullPattern = $prefix . $pattern;
$keys = $redis->keys($fullPattern);
foreach ($keys as $key) {
// Remove the prefix to get the actual cache key
$cacheKey = str_replace($prefix, '', $key);
Cache::forget($cacheKey);
$clearedCount++;
}
}
if ($clearedCount === 0) {
$this->comment('No cache entries found to clear.');
} else {
$this->info("Successfully cleared {$clearedCount} cache entries.");
}
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error("Error clearing cache: " . $e->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands;
use App\Jobs\FetchAttractionsJob;
use App\Models\City;
use App\Services\OpenTripMapService;
use App\Services\OverpassService;
use Illuminate\Console\Attributes\Argument;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('attractions:refresh {cityId}')]
#[Description('Refresh attraction data for a specific city')]
#[Argument('cityId', description: 'The ID of the city to refresh attractions for')]
class RefreshAttractions extends Command
{
/**
* Execute the console command.
*/
public function handle(
OpenTripMapService $openTripMapService,
OverpassService $overpassService
): int {
$cityId = $this->argument('cityId');
// Find the city
$city = City::find($cityId);
if (!$city) {
$this->error("City with ID {$cityId} not found.");
return Command::FAILURE;
}
$this->info("Refreshing attractions for {$city->name}, {$city->country}...");
if (!$city->latitude || !$city->longitude) {
$this->error("City coordinates not set. Cannot fetch attractions.");
return Command::FAILURE;
}
try {
// Clear cache for this city's coordinates
$this->info("Clearing cached data...");
$openTripMapService->clearCache($city->latitude, $city->longitude);
$overpassService->clearCache($city->latitude, $city->longitude);
// Delete existing attractions for this city
$deletedCount = $city->attractions()->delete();
$this->info("Deleted {$deletedCount} existing attractions.");
// Dispatch job to fetch new attractions
$this->info("Fetching fresh attraction data...");
FetchAttractionsJob::dispatch($city, true);
$this->info("Attractions refresh job dispatched successfully.");
$this->comment("The job will process in the background. Check logs for completion status.");
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error("Error refreshing attractions: " . $e->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Console\Commands;
use App\Services\WikipediaService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('test:wikipedia')]
#[Description('Test Wikipedia API integration')]
class TestWikipediaCommand extends Command
{
public function __construct(
private WikipediaService $wikipediaService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Testing Wikipedia API integration...');
$this->newLine();
// Test 1: Search for cities
$this->info('Test 1: Searching for "Paris"');
try {
$results = $this->wikipediaService->searchCities('Paris');
$this->line('Found ' . count($results) . ' results:');
foreach (array_slice($results, 0, 3) as $result) {
$this->line(' - ' . $result['title'] . ': ' . $result['description']);
}
$this->newLine();
} catch (\Exception $e) {
$this->error('Search failed: ' . $e->getMessage());
return 1;
}
// Test 2: Get city summary
$this->info('Test 2: Getting summary for "Paris"');
try {
$cityData = $this->wikipediaService->getCitySummary('Paris');
if ($cityData) {
$this->line('Title: ' . $cityData['title']);
$this->line('Summary: ' . substr($cityData['summary'], 0, 200) . '...');
$this->line('Coordinates: ' . $cityData['latitude'] . ', ' . $cityData['longitude']);
$this->line('URL: ' . $cityData['url']);
} else {
$this->warn('No data found for Paris');
}
$this->newLine();
} catch (\Exception $e) {
$this->error('Summary fetch failed: ' . $e->getMessage());
return 1;
}
// Test 3: Test caching
$this->info('Test 3: Testing cache (fetching Paris again)');
$start = microtime(true);
$this->wikipediaService->getCitySummary('Paris');
$elapsed = (microtime(true) - $start) * 1000;
$this->line('Fetch time: ' . round($elapsed, 2) . 'ms (should be fast if cached)');
$this->newLine();
$this->info('✓ All tests completed successfully!');
return 0;
}
}
+251
View File
@@ -0,0 +1,251 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\CityResource;
use App\Http\Resources\AttractionResource;
use App\Models\City;
use App\Jobs\FetchCityDataJob;
use App\Services\WikipediaService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class CityController extends Controller
{
/**
* Search for cities
*/
public function search(Request $request, WikipediaService $wikipediaService)
{
$validated = $request->validate([
'q' => 'required|string|min:2|max:255',
]);
$query = $validated['q'];
// Add to search history if session is available (for web routes)
if ($request->hasSession()) {
$this->addToSearchHistory($request, $query);
}
// Search in local database first
$localResults = $this->searchLocalCities($query);
$wikipediaResults = null;
// If no local results, fetch from Wikipedia and create new city
if ($localResults->isEmpty()) {
$newCity = $this->createCityFromWikipedia($query, $wikipediaService);
if ($newCity) {
$localResults = collect([$newCity]);
}
}
$response = [
'local' => CityResource::collection($localResults),
'wikipedia' => $wikipediaResults,
];
// Add recent searches if session is available
if ($request->hasSession()) {
$response['recent_searches'] = $request->session()->get('search_history', []);
}
return response()->json($response);
}
/**
* Add search query to session history
* Note: This method should only be called when hasSession() is true
*/
private function addToSearchHistory(Request $request, string $query): void
{
if (!$request->hasSession()) {
return;
}
$searchHistory = $request->session()->get('search_history', []);
// Remove duplicate if exists
$searchHistory = array_filter($searchHistory, function($item) use ($query) {
return strtolower($item) !== strtolower($query);
});
// Add new search to the beginning
array_unshift($searchHistory, $query);
// Keep only last 10 searches
$searchHistory = array_slice($searchHistory, 0, 10);
$request->session()->put('search_history', $searchHistory);
}
/**
* Search for cities in local database
*/
private function searchLocalCities(string $query)
{
return City::where('name', 'LIKE', "%{$query}%")
->orWhere('country', 'LIKE', "%{$query}%")
->limit(10)
->get();
}
/**
* Create a city from Wikipedia data
*/
private function createCityFromWikipedia(string $query, WikipediaService $wikipediaService)
{
try {
$wikipediaData = $wikipediaService->getCitySummary($query);
if (!$wikipediaData) {
return null;
}
$countryName = $this->fetchCountryName($wikipediaData['title']);
$city = City::create([
'name' => $wikipediaData['title'],
'country' => $countryName,
'latitude' => $wikipediaData['latitude'] ?? null,
'longitude' => $wikipediaData['longitude'] ?? null,
'wikipedia_summary' => $wikipediaData['summary'] ?? null,
'wikipedia_url' => $wikipediaData['url'] ?? null,
'thumbnail_url' => $wikipediaData['thumbnail'] ?? null,
'image_url' => $wikipediaData['image'] ?? null,
'wikipedia_page_id' => $wikipediaData['page_id'] ?? null,
'wikipedia_cached_at' => now(),
]);
// Dispatch job to fetch attractions for the newly created city
if ($city && $city->latitude && $city->longitude) {
\App\Jobs\FetchAttractionsJob::dispatch($city);
}
return $city;
} catch (\Exception $e) {
\Log::error('Failed to create city from Wikipedia', [
'query' => $query,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Fetch country name from external API
*/
private function fetchCountryName(string $cityName)
{
try {
$response = Http::post('https://countriesnow.space/api/v0.1/countries/population/cities', [
'city' => $cityName,
]);
if ($response->successful()) {
$data = $response->json();
return $data['data']['country'] ?? 'Unknown';
}
return 'Unknown';
} catch (\Exception $e) {
\Log::error('Failed to fetch country name', [
'city' => $cityName,
'error' => $e->getMessage(),
]);
return 'Unknown';
}
}
/**
* Display the specified city
*/
public function show(City $city)
{
$city->loadCount('attractions');
// fetch attractions, or dispatch a job to fetch them
return new CityResource($city);
}
/**
* Get attractions for a city with pagination
*/
public function attractions(Request $request, City $city)
{
$validated = $request->validate([
'category' => 'nullable|string|max:50',
'limit' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
]);
$query = $city->attractions()
->select(['id', 'city_id', 'name', 'description', 'latitude', 'longitude', 'category', 'rating', 'image_url'])
->orderBy('name', 'asc');
// Filter by category if provided
if ($request->has('category') && $request->category) {
$query->where('category', $request->category);
}
// Use pagination for better performance
$perPage = $request->input('limit', 20);
$attractions = $query->paginate($perPage);
return AttractionResource::collection($attractions);
}
/**
* Get map data for a city with optimized query
*/
public function mapData(City $city)
{
// Eager load only necessary columns for map display
$city->load(['attractions' => function ($query) {
$query->select(['id', 'city_id', 'name', 'latitude', 'longitude', 'category']);
}]);
return response()->json([
'city' => [
'name' => $city->name,
'latitude' => (float) $city->latitude,
'longitude' => (float) $city->longitude,
],
'attractions' => $city->attractions->map(function ($attraction) {
return [
'id' => $attraction->id,
'name' => $attraction->name,
'latitude' => (float) $attraction->latitude,
'longitude' => (float) $attraction->longitude,
'category' => $attraction->category,
];
}),
]);
}
/**
* Add a new city (Protected)
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'country' => 'required|string|max:255',
'latitude' => 'nullable|numeric|between:-90,90',
'longitude' => 'nullable|numeric|between:-180,180',
]);
$city = City::create($validated);
// Dispatch job to fetch data from Wikipedia
FetchCityDataJob::dispatch($city);
return new CityResource($city);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\CityResource;
use App\Models\City;
use Illuminate\Http\Request;
class FavoriteController extends Controller
{
/**
* Get user's favorite cities
*/
public function index(Request $request)
{
$favorites = $request->user()
->favoriteCities()
->withCount('attractions')
->get();
return CityResource::collection($favorites);
}
/**
* Add city to favorites
*/
public function store(Request $request, City $city)
{
$user = $request->user();
// Check if already favorited
if ($user->favoriteCities()->where('city_id', $city->id)->exists()) {
return response()->json([
'message' => 'City already in favorites',
], 409);
}
$user->favoriteCities()->attach($city->id);
return response()->json([
'message' => 'City added to favorites',
'city' => new CityResource($city),
], 201);
}
/**
* Remove city from favorites
*/
public function destroy(Request $request, City $city)
{
$user = $request->user();
$user->favoriteCities()->detach($city->id);
return response()->json([
'message' => 'City removed from favorites',
]);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\TripResource;
use App\Models\Trip;
use Illuminate\Http\Request;
class TripController extends Controller
{
/**
* Display a listing of user's trips
*/
public function index(Request $request)
{
$trips = $request->user()
->trips()
->withCount('cities')
->latest()
->get();
return TripResource::collection($trips);
}
/**
* Store a newly created trip
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'city_ids' => 'nullable|array',
'city_ids.*' => 'exists:cities,id',
]);
$trip = $request->user()->trips()->create([
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
]);
// Attach cities if provided
if (!empty($validated['city_ids'])) {
$cityData = [];
foreach ($validated['city_ids'] as $index => $cityId) {
$cityData[$cityId] = ['order' => $index];
}
$trip->cities()->attach($cityData);
}
$trip->load('cities')->loadCount('cities');
return new TripResource($trip);
}
/**
* Display the specified trip
*/
public function show(Request $request, Trip $trip)
{
// Check if trip belongs to user
if ($trip->user_id !== $request->user()->id) {
return response()->json([
'message' => 'Unauthorized',
], 403);
}
$trip->load('cities')->loadCount('cities');
return new TripResource($trip);
}
/**
* Update the specified trip
*/
public function update(Request $request, Trip $trip)
{
// Check if trip belongs to user
if ($trip->user_id !== $request->user()->id) {
return response()->json([
'message' => 'Unauthorized',
], 403);
}
$validated = $request->validate([
'name' => 'sometimes|required|string|max:255',
'description' => 'nullable|string',
'city_ids' => 'nullable|array',
'city_ids.*' => 'exists:cities,id',
]);
$trip->update([
'name' => $validated['name'] ?? $trip->name,
'description' => $validated['description'] ?? $trip->description,
]);
// Update cities if provided
if (isset($validated['city_ids'])) {
$cityData = [];
foreach ($validated['city_ids'] as $index => $cityId) {
$cityData[$cityId] = ['order' => $index];
}
$trip->cities()->sync($cityData);
}
$trip->load('cities')->loadCount('cities');
return new TripResource($trip);
}
/**
* Remove the specified trip
*/
public function destroy(Request $request, Trip $trip)
{
// Check if trip belongs to user
if ($trip->user_id !== $request->user()->id) {
return response()->json([
'message' => 'Unauthorized',
], 403);
}
$trip->delete();
return response()->json([
'message' => 'Trip deleted successfully',
]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\UserResource;
use Illuminate\Http\Request;
class UserController extends Controller
{
/**
* Display the authenticated user's profile
*/
public function show(Request $request)
{
return new UserResource($request->user());
}
/**
* Update the authenticated user's profile
*/
public function update(Request $request)
{
$validated = $request->validate([
'name' => 'sometimes|required|string|max:255',
'email' => 'sometimes|required|email|unique:users,email,' . $request->user()->id,
]);
$request->user()->update($validated);
return new UserResource($request->user());
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
/**
* Show the login form
*/
public function showLoginForm()
{
return view('auth.login');
}
/**
* Handle login request
*/
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials, $request->filled('remember'))) {
$request->session()->regenerate();
return redirect()->intended('/');
}
throw ValidationException::withMessages([
'email' => 'The provided credentials do not match our records.',
]);
}
/**
* Handle logout request
*/
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class RegisterController extends Controller
{
/**
* Show the registration form
*/
public function showRegistrationForm()
{
return view('auth.register');
}
/**
* Handle registration request
*/
public function register(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
Auth::login($user);
return redirect('/');
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Register a new user
*/
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
], 201);
}
/**
* Login user
*/
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
$token = $user->createToken('auth_token')->plainTextToken;
return response()->json([
'user' => $user,
'token' => $token,
'token_type' => 'Bearer',
]);
}
/**
* Logout user (Revoke token)
*/
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Successfully logged out',
]);
}
/**
* Get authenticated user
*/
public function user(Request $request)
{
return response()->json($request->user());
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Models\City;
use Illuminate\Http\Request;
class CityController extends Controller
{
/**
* Display the city details page
*/
public function show(City $city)
{
$city->load('attractions');
return view('cities.show', compact('city'));
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class AttractionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'latitude' => (float) $this->latitude,
'longitude' => (float) $this->longitude,
'category' => $this->category,
'source' => $this->source,
'rating' => $this->rating ? (float) $this->rating : null,
'image_url' => $this->image_url,
'city' => new CityResource($this->whenLoaded('city')),
];
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CityResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->name,
'country' => $this->country,
'latitude' => (float) $this->latitude,
'longitude' => (float) $this->longitude,
'wikipedia_summary' => $this->wikipedia_summary,
'wikipedia_url' => $this->wikipedia_url,
'attractions_count' => $this->whenCounted('attractions'),
'attractions' => AttractionResource::collection($this->whenLoaded('attractions')),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TripResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'user' => new UserResource($this->whenLoaded('user')),
'cities' => CityResource::collection($this->whenLoaded('cities')),
'cities_count' => $this->whenCounted('cities'),
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'email_verified_at' => $this->email_verified_at?->toISOString(),
'created_at' => $this->created_at?->toISOString(),
];
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Jobs;
use App\Models\City;
use App\Models\Attraction;
use App\Services\OpenTripMapService;
use App\Services\OverpassService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class FetchAttractionsJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 120;
/**
* Create a new job instance.
*/
public function __construct(
public City $city,
public bool $forceRefresh = false
) {
}
/**
* Execute the job.
*/
public function handle(
OpenTripMapService $openTripMapService,
OverpassService $overpassService
): void {
try {
// Check if we need to refresh
if (!$this->forceRefresh) {
$lastAttraction = $this->city->attractions()
->orderBy('cached_at', 'desc')
->first();
if ($lastAttraction && $lastAttraction->cached_at) {
$cacheAge = now()->diffInDays($lastAttraction->cached_at);
$cacheTtl = config('services.opentripmap.cache_ttl', 7);
if ($cacheAge < $cacheTtl) {
Log::info("Attractions data is still fresh, skipping fetch", [
'city_id' => $this->city->id,
'cache_age' => $cacheAge,
]);
return;
}
}
}
if (!$this->city->latitude || !$this->city->longitude) {
Log::warning("City coordinates not set, cannot fetch attractions", [
'city_id' => $this->city->id,
]);
return;
}
$attractionsCreated = 0;
// Fetch from OpenTripMap
$openTripMapData = $openTripMapService->getAttractions(
$this->city->latitude,
$this->city->longitude
);
foreach ($openTripMapData as $data) {
$this->createOrUpdateAttraction($data, 'opentripmap');
$attractionsCreated++;
}
// Fetch from Overpass
$overpassData = $overpassService->getPointsOfInterest(
$this->city->latitude,
$this->city->longitude
);
foreach ($overpassData as $data) {
$this->createOrUpdateAttraction($data, 'overpass');
$attractionsCreated++;
}
Log::info("Attractions fetched successfully", [
'city_id' => $this->city->id,
'city_name' => $this->city->name,
'attractions_created' => $attractionsCreated,
]);
} catch (\Exception $e) {
Log::error("Error in FetchAttractionsJob", [
'city_id' => $this->city->id,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Create or update an attraction
*/
private function createOrUpdateAttraction(array $data, string $source): void
{
Attraction::updateOrCreate(
[
'city_id' => $this->city->id,
'source' => $source,
'source_id' => $data['source_id'],
],
[
'name' => $data['name'],
'description' => $data['description'] ?? null,
'latitude' => $data['latitude'],
'longitude' => $data['longitude'],
'category' => $data['category'] ?? 'other',
'rating' => $data['rating'] ?? null,
'image_url' => $data['image_url'] ?? null,
'cached_at' => now(),
]
);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Jobs;
use App\Models\City;
use App\Services\WikipediaService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class FetchCityDataJob implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 60;
/**
* Create a new job instance.
*/
public function __construct(
public City $city,
public bool $forceRefresh = false
) {
}
/**
* Execute the job.
*/
public function handle(WikipediaService $wikipediaService): void
{
try {
// Check if we need to refresh the data
if (!$this->forceRefresh && $this->city->wikipedia_cached_at) {
$cacheAge = now()->diffInDays($this->city->wikipedia_cached_at);
$cacheTtl = config('services.wikipedia.cache_ttl', 30);
if ($cacheAge < $cacheTtl) {
Log::info("City data is still fresh, skipping fetch", [
'city_id' => $this->city->id,
'cache_age' => $cacheAge,
]);
return;
}
}
// Fetch data from Wikipedia
$data = $wikipediaService->getCitySummary($this->city->name);
if ($data) {
// Update city with Wikipedia data
$this->city->update([
'wikipedia_summary' => $data['summary'],
'wikipedia_url' => $data['url'],
'thumbnail_url' => $data['thumbnail'] ?? null,
'image_url' => $data['image'] ?? null,
'wikipedia_page_id' => $data['page_id'],
'wikipedia_cached_at' => now(),
]);
// Update coordinates if they exist in Wikipedia data and not set
if (isset($data['latitude']) && isset($data['longitude'])) {
if (!$this->city->latitude || !$this->city->longitude) {
$this->city->update([
'latitude' => $data['latitude'],
'longitude' => $data['longitude'],
]);
}
}
Log::info("City data fetched successfully", [
'city_id' => $this->city->id,
'city_name' => $this->city->name,
]);
// Dispatch job to fetch attractions
FetchAttractionsJob::dispatch($this->city);
} else {
Log::warning("Failed to fetch city data from Wikipedia", [
'city_id' => $this->city->id,
'city_name' => $this->city->name,
]);
}
} catch (\Exception $e) {
Log::error("Error in FetchCityDataJob", [
'city_id' => $this->city->id,
'error' => $e->getMessage(),
]);
throw $e; // Re-throw to trigger retry
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Attraction extends Model
{
use HasFactory;
protected $fillable = [
'city_id',
'name',
'description',
'latitude',
'longitude',
'category',
'source',
'source_id',
'rating',
'image_url',
'cached_at',
];
protected $casts = [
'latitude' => 'decimal:7',
'longitude' => 'decimal:7',
'rating' => 'decimal:2',
'cached_at' => 'datetime',
];
/**
* Get the city that owns this attraction
*/
public function city(): BelongsTo
{
return $this->belongsTo(City::class);
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
class City extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'country',
'latitude',
'longitude',
'wikipedia_summary',
'wikipedia_url',
'thumbnail_url',
'image_url',
'wikipedia_page_id',
'wikipedia_cached_at',
];
protected $casts = [
'latitude' => 'decimal:7',
'longitude' => 'decimal:7',
'wikipedia_cached_at' => 'datetime',
];
/**
* Get the route key for the model.
*/
public function getRouteKeyName(): string
{
return 'slug';
}
/**
* Boot the model and set up event listeners
*/
protected static function boot()
{
parent::boot();
static::creating(function ($city) {
if (empty($city->slug)) {
$city->slug = static::generateUniqueSlug($city->name);
}
});
static::updating(function ($city) {
if ($city->isDirty('name') && empty($city->slug)) {
$city->slug = static::generateUniqueSlug($city->name);
}
});
}
/**
* Generate a unique slug from city name
*/
protected static function generateUniqueSlug(string $name): string
{
$slug = Str::slug($name);
$originalSlug = $slug;
$count = 1;
while (static::where('slug', $slug)->exists()) {
$slug = $originalSlug . '-' . $count;
$count++;
}
return $slug;
}
/**
* Get all attractions for this city
*/
public function attractions(): HasMany
{
return $this->hasMany(Attraction::class);
}
/**
* Get all trips that include this city
*/
public function trips(): BelongsToMany
{
return $this->belongsToMany(Trip::class, 'city_trip')
->withPivot('order')
->withTimestamps()
->orderByPivot('order');
}
/**
* Get users who favorited this city
*/
public function favoritedBy(): BelongsToMany
{
return $this->belongsToMany(User::class, 'favorites')
->withTimestamps();
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Favorite extends Model
{
protected $fillable = [
'user_id',
'city_id',
];
/**
* Get the user that owns this favorite
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the city that was favorited
*/
public function city(): BelongsTo
{
return $this->belongsTo(City::class);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Trip extends Model
{
protected $fillable = [
'user_id',
'name',
'description',
];
/**
* Get the user that owns this trip
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get all cities in this trip
*/
public function cities(): BelongsToMany
{
return $this->belongsToMany(City::class, 'city_trip')
->withPivot('order')
->withTimestamps()
->orderByPivot('order');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, HasApiTokens;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/**
* Get all favorites for this user
*/
public function favorites(): HasMany
{
return $this->hasMany(Favorite::class);
}
/**
* Get all favorite cities for this user
*/
public function favoriteCities(): BelongsToMany
{
return $this->belongsToMany(City::class, 'favorites')
->withTimestamps();
}
/**
* Get all trips for this user
*/
public function trips(): HasMany
{
return $this->hasMany(Trip::class);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class OpenTripMapService
{
private string $baseUrl;
private string $apiKey;
private int $cacheTtl;
public function __construct()
{
$this->baseUrl = 'https://api.opentripmap.com/0.1/en/places';
$this->apiKey = config('services.opentripmap.key', env('OPENTRIPMAP_API_KEY'));
$this->cacheTtl = config('services.opentripmap.cache_ttl', 7) * 24 * 60; // Convert days to minutes
}
/**
* Get attractions near coordinates
*
* @param float $lat
* @param float $lon
* @param int $radius in meters (default 5000m = 5km)
* @param int $limit
* @return array
*/
public function getAttractions(float $lat, float $lon, int $radius = 5000, int $limit = 50): array
{
$cacheKey = "opentripmap:attractions:{$lat}:{$lon}:{$radius}:{$limit}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($lat, $lon, $radius, $limit) {
try {
$response = Http::timeout(15)
->get("{$this->baseUrl}/radius", [
'apikey' => $this->apiKey,
'radius' => $radius,
'lon' => $lon,
'lat' => $lat,
'limit' => $limit,
'format' => 'json',
]);
if ($response->successful()) {
$features = $response->json('features', []);
$attractions = [];
foreach ($features as $feature) {
$properties = $feature['properties'] ?? [];
$geometry = $feature['geometry'] ?? [];
$coordinates = $geometry['coordinates'] ?? [];
if (!empty($properties['name'])) {
$attractions[] = [
'name' => $properties['name'],
'source_id' => $properties['xid'] ?? null,
'latitude' => $coordinates[1] ?? null,
'longitude' => $coordinates[0] ?? null,
'category' => $this->mapCategory($properties['kinds'] ?? ''),
'rating' => $properties['rate'] ?? null,
];
}
}
return $attractions;
}
Log::warning("OpenTripMap API request failed", [
'status' => $response->status(),
'lat' => $lat,
'lon' => $lon,
]);
return [];
} catch (\Exception $e) {
Log::error("OpenTripMap API error", [
'error' => $e->getMessage(),
'lat' => $lat,
'lon' => $lon,
]);
return [];
}
});
}
/**
* Get detailed information about an attraction
*
* @param string $xid OpenTripMap attraction ID
* @return array|null
*/
public function getAttractionDetails(string $xid): ?array
{
$cacheKey = "opentripmap:details:{$xid}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($xid) {
try {
$response = Http::timeout(10)
->get("{$this->baseUrl}/xid/{$xid}", [
'apikey' => $this->apiKey,
]);
if ($response->successful()) {
$data = $response->json();
return [
'name' => $data['name'] ?? null,
'description' => $data['wikipedia_extracts']['text'] ?? $data['info']['descr'] ?? null,
'image_url' => $data['preview']['source'] ?? $data['image'] ?? null,
'wikipedia_url' => $data['wikipedia'] ?? null,
'rating' => $data['rate'] ?? null,
];
}
return null;
} catch (\Exception $e) {
Log::error("OpenTripMap details error for xid: {$xid}", [
'error' => $e->getMessage(),
]);
return null;
}
});
}
/**
* Map OpenTripMap categories to our simplified categories
*
* @param string $kinds
* @return string
*/
private function mapCategory(string $kinds): string
{
$kinds = strtolower($kinds);
if (str_contains($kinds, 'museums')) {
return 'museum';
} elseif (str_contains($kinds, 'theatres_and_entertainments') || str_contains($kinds, 'cinemas')) {
return 'entertainment';
} elseif (str_contains($kinds, 'cultural') || str_contains($kinds, 'interesting_places')) {
return 'cultural';
} elseif (str_contains($kinds, 'architecture') || str_contains($kinds, 'historic')) {
return 'historic';
} elseif (str_contains($kinds, 'natural') || str_contains($kinds, 'nature_reserves')) {
return 'nature';
} elseif (str_contains($kinds, 'sport')) {
return 'sport';
} elseif (str_contains($kinds, 'food') || str_contains($kinds, 'restaurants')) {
return 'restaurant';
}
return 'other';
}
/**
* Clear cache for specific coordinates
*
* @param float $lat
* @param float $lon
* @return void
*/
public function clearCache(float $lat, float $lon): void
{
// Clear all related cache keys
Cache::forget("opentripmap:attractions:{$lat}:{$lon}:5000:50");
}
}
+221
View File
@@ -0,0 +1,221 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class OverpassService
{
private string $baseUrl;
private int $cacheTtl;
public function __construct()
{
$this->baseUrl = config('services.overpass.url', 'https://overpass-api.de/api/interpreter');
$this->cacheTtl = config('services.overpass.cache_ttl', 7) * 24 * 60; // Convert days to minutes
}
/**
* Get points of interest near coordinates
*
* @param float $lat
* @param float $lon
* @param int $radius in meters (default 5000m = 5km)
* @return array
*/
public function getPointsOfInterest(float $lat, float $lon, int $radius = 5000): array
{
$cacheKey = "overpass:poi:{$lat}:{$lon}:{$radius}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($lat, $lon, $radius) {
try {
// Overpass QL query for tourism and amenity nodes
$query = "[out:json][timeout:25];
(
node[\"tourism\"~\"attraction|museum|artwork|viewpoint\"](around:{$radius},{$lat},{$lon});
node[\"amenity\"~\"restaurant|cafe|bar|theatre|cinema\"](around:{$radius},{$lat},{$lon});
node[\"historic\"](around:{$radius},{$lat},{$lon});
);
out body;
>;
out skel qt;";
$response = Http::timeout(30)
->asForm()
->post($this->baseUrl, [
'data' => $query,
]);
if ($response->successful()) {
$data = $response->json();
$elements = $data['elements'] ?? [];
$attractions = [];
foreach ($elements as $element) {
if ($element['type'] === 'node' && !empty($element['tags']['name'])) {
$tags = $element['tags'];
$attractions[] = [
'name' => $tags['name'],
'source_id' => (string) $element['id'],
'latitude' => $element['lat'],
'longitude' => $element['lon'],
'category' => $this->determineCategory($tags),
'description' => $tags['description'] ?? null,
'website' => $tags['website'] ?? null,
'opening_hours' => $tags['opening_hours'] ?? null,
];
}
}
return $attractions;
}
Log::warning("Overpass API request failed", [
'status' => $response->status(),
'lat' => $lat,
'lon' => $lon,
]);
return [];
} catch (\Exception $e) {
Log::error("Overpass API error", [
'error' => $e->getMessage(),
'lat' => $lat,
'lon' => $lon,
]);
return [];
}
});
}
/**
* Determine category from OSM tags
*
* @param array $tags
* @return string
*/
private function determineCategory(array $tags): string
{
// Tourism tags
if (isset($tags['tourism'])) {
return match ($tags['tourism']) {
'museum' => 'museum',
'artwork', 'gallery' => 'cultural',
'attraction', 'viewpoint' => 'attraction',
'hotel', 'hostel', 'guest_house' => 'accommodation',
default => 'tourism',
};
}
// Amenity tags
if (isset($tags['amenity'])) {
return match ($tags['amenity']) {
'restaurant', 'fast_food', 'food_court' => 'restaurant',
'cafe', 'bar', 'pub' => 'cafe',
'theatre', 'cinema' => 'entertainment',
'library' => 'cultural',
default => 'amenity',
};
}
// Historic tags
if (isset($tags['historic'])) {
return 'historic';
}
// Leisure tags
if (isset($tags['leisure'])) {
return match ($tags['leisure']) {
'park', 'garden' => 'park',
'sports_centre', 'stadium' => 'sport',
default => 'leisure',
};
}
return 'other';
}
/**
* Get restaurants and cafes near coordinates
*
* @param float $lat
* @param float $lon
* @param int $radius
* @return array
*/
public function getRestaurants(float $lat, float $lon, int $radius = 2000): array
{
$cacheKey = "overpass:restaurants:{$lat}:{$lon}:{$radius}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($lat, $lon, $radius) {
try {
$query = "[out:json][timeout:25];
(
node[\"amenity\"=\"restaurant\"](around:{$radius},{$lat},{$lon});
node[\"amenity\"=\"cafe\"](around:{$radius},{$lat},{$lon});
node[\"amenity\"=\"bar\"](around:{$radius},{$lat},{$lon});
);
out body;";
$response = Http::timeout(30)
->asForm()
->post($this->baseUrl, [
'data' => $query,
]);
if ($response->successful()) {
$data = $response->json();
$elements = $data['elements'] ?? [];
$restaurants = [];
foreach ($elements as $element) {
if ($element['type'] === 'node' && !empty($element['tags']['name'])) {
$tags = $element['tags'];
$restaurants[] = [
'name' => $tags['name'],
'source_id' => (string) $element['id'],
'latitude' => $element['lat'],
'longitude' => $element['lon'],
'category' => 'restaurant',
'cuisine' => $tags['cuisine'] ?? null,
'phone' => $tags['phone'] ?? null,
'website' => $tags['website'] ?? null,
'opening_hours' => $tags['opening_hours'] ?? null,
];
}
}
return $restaurants;
}
return [];
} catch (\Exception $e) {
Log::error("Overpass restaurants error", [
'error' => $e->getMessage(),
'lat' => $lat,
'lon' => $lon,
]);
return [];
}
});
}
/**
* Clear cache for specific coordinates
*
* @param float $lat
* @param float $lon
* @return void
*/
public function clearCache(float $lat, float $lon): void
{
Cache::forget("overpass:poi:{$lat}:{$lon}:5000");
Cache::forget("overpass:restaurants:{$lat}:{$lon}:2000");
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class WikipediaService
{
private string $baseUrl;
private int $cacheTtl;
public function __construct()
{
$this->baseUrl = config('services.wikipedia.url', 'https://en.wikipedia.org/api/rest_v1');
$this->cacheTtl = config('services.wikipedia.cache_ttl', 30) * 24 * 60; // Convert days to minutes
}
/**
* Get city summary from Wikipedia
*
* @param string $cityName
* @return array|null
*/
public function getCitySummary(string $cityName): ?array
{
$cacheKey = "wikipedia:summary:{$cityName}";
return Cache::remember($cacheKey, $this->cacheTtl, function () use ($cityName) {
try {
$response = Http::withHeaders([
'User-Agent' => 'WikiTravelApp/1.0 (contact@example.com)'
])
->timeout(10)
->get("{$this->baseUrl}/page/summary/" . urlencode($cityName));
if ($response->successful()) {
$data = $response->json();
Log::info("Wiki message ". json_encode($data));
return [
'title' => $data['title'] ?? null,
'country' => $data['description'] ?? null,
'summary' => $data['extract'] ?? null,
'url' => $data['content_urls']['desktop']['page'] ?? null,
'page_id' => $data['pageid'] ?? null,
'thumbnail' => $data['thumbnail']['source'] ?? null,
'image' => $data['originalimage']['source'] ?? null,
'latitude' => $data['coordinates']['lat'] ?? null,
'longitude' => $data['coordinates']['lon'] ?? null,
];
}
Log::warning("Wikipedia API request failed for city: {$cityName}", [
'status' => $response->status(),
]);
return null;
} catch (\Exception $e) {
Log::error("Wikipedia API error for city: {$cityName}", [
'error' => $e->getMessage(),
]);
return null;
}
});
}
/**
* Search for cities on Wikipedia
*
* @param string $query
* @param int $limit
* @return array
*/
public function searchCities(string $query, int $limit = 10): array
{
try {
$response = Http::withHeaders([
'User-Agent' => 'WikiTravelApp/1.0 (contact@example.com)'
])
->timeout(10)
->get("{$this->baseUrl}/../w/api.php", [
'action' => 'opensearch',
'search' => $query,
'limit' => $limit,
'namespace' => 0,
'format' => 'json',
]);
if ($response->successful()) {
$data = $response->json();
$results = [];
$titles = $data[1] ?? [];
$descriptions = $data[2] ?? [];
$urls = $data[3] ?? [];
foreach ($titles as $index => $title) {
$results[] = [
'title' => $title,
'description' => $descriptions[$index] ?? '',
'url' => $urls[$index] ?? '',
];
}
return $results;
}
return [];
} catch (\Exception $e) {
Log::error("Wikipedia search error for query: {$query}", [
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* Clear cache for a specific city
*
* @param string $cityName
* @return void
*/
public function clearCache(string $cityName): void
{
Cache::forget("wikipedia:summary:{$cityName}");
}
}
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+30
View File
@@ -0,0 +1,30 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->api(prepend: [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
$middleware->alias([
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
]);
// Configure CORS for mobile app
$middleware->validateCsrfTokens(except: [
'api/*',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+74
View File
@@ -0,0 +1,74 @@
services:
laravel.test:
build:
context: './vendor/laravel/sail/runtimes/8.5'
dockerfile: Dockerfile
args:
WWWGROUP: '${WWWGROUP}'
image: 'sail-8.5/app'
extra_hosts:
- 'host.docker.internal:host-gateway'
ports:
- '${APP_PORT:-81}:80'
- '${VITE_PORT:-5174}:${VITE_PORT:-5174}'
environment:
WWWUSER: '${WWWUSER}'
LARAVEL_SAIL: 1
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
IGNITION_LOCAL_SITES_PATH: '${PWD}'
volumes:
- '.:/var/www/html'
networks:
- sail
depends_on:
- mysql
- redis
mysql:
image: 'mysql:8.0'
ports:
- '${FORWARD_DB_PORT:-3306}:3306'
environment:
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
MYSQL_ROOT_HOST: '%'
MYSQL_DATABASE: '${DB_DATABASE}'
MYSQL_USER: '${DB_USERNAME}'
MYSQL_PASSWORD: '${DB_PASSWORD}'
MYSQL_ALLOW_EMPTY_PASSWORD: 1
MYSQL_EXTRA_OPTIONS: '${MYSQL_EXTRA_OPTIONS:-}'
volumes:
- 'sail-mysql:/var/lib/mysql'
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
networks:
- sail
healthcheck:
test:
- CMD
- mysqladmin
- ping
- '-p${DB_PASSWORD}'
retries: 3
timeout: 5s
redis:
image: 'redis:alpine'
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
- 'sail-redis:/data'
networks:
- sail
healthcheck:
test:
- CMD
- redis-cli
- ping
retries: 3
timeout: 5s
networks:
sail:
driver: bridge
volumes:
sail-mysql:
driver: local
sail-redis:
driver: local
+91
View File
@@ -0,0 +1,91 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.5",
"laravel/pint": "^1.27",
"laravel/sail": "^1.56",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.4",
"pestphp/pest-plugin-laravel": "^4.1"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install --ignore-scripts",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+9295
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+117
View File
@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+130
View File
@@ -0,0 +1,130 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'redis'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+132
View File
@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+84
View File
@@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];
+53
View File
@@ -0,0 +1,53 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
'wikipedia' => [
'url' => env('WIKIPEDIA_API_URL', 'https://en.wikipedia.org/api/rest_v1'),
'cache_ttl' => env('CACHE_WIKIPEDIA_TTL', 30), // days
],
'opentripmap' => [
'key' => env('OPENTRIPMAP_API_KEY'),
'cache_ttl' => env('CACHE_ATTRACTIONS_TTL', 7), // days
],
'overpass' => [
'url' => env('OVERPASS_API_URL', 'https://overpass-api.de/api/interpreter'),
'cache_ttl' => env('CACHE_ATTRACTIONS_TTL', 7), // days
],
];
+233
View File
@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\Attraction;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Attraction>
*/
class AttractionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$categories = ['museum', 'restaurant', 'historic', 'park', 'cultural', 'entertainment', 'other'];
return [
'city_id' => \App\Models\City::factory(),
'name' => fake()->company() . ' ' . fake()->randomElement(['Museum', 'Restaurant', 'Park', 'Theater', 'Gallery']),
'description' => fake()->paragraph(),
'latitude' => fake()->latitude(),
'longitude' => fake()->longitude(),
'category' => fake()->randomElement($categories),
'source' => fake()->randomElement(['opentripmap', 'overpass', 'wikipedia']),
'source_id' => (string) fake()->randomNumber(8),
'rating' => fake()->optional(0.7)->randomFloat(2, 1, 5),
'image_url' => fake()->optional(0.5)->imageUrl(),
'cached_at' => now(),
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\City;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<City>
*/
class CityFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = fake()->city();
return [
'name' => $name,
'slug' => \Illuminate\Support\Str::slug($name) . '-' . fake()->randomNumber(3),
'country' => fake()->country(),
'latitude' => fake()->latitude(),
'longitude' => fake()->longitude(),
'wikipedia_summary' => fake()->paragraph(3),
'wikipedia_url' => fake()->url(),
'wikipedia_page_id' => fake()->randomNumber(6),
'wikipedia_cached_at' => now(),
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?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('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?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('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,57 @@
<?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('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,33 @@
<?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('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
@@ -0,0 +1,39 @@
<?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('cities', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('country');
$table->decimal('latitude', 10, 7);
$table->decimal('longitude', 10, 7);
$table->text('wikipedia_summary')->nullable();
$table->string('wikipedia_url')->nullable();
$table->string('wikipedia_page_id')->nullable();
$table->timestamps();
$table->timestamp('wikipedia_cached_at')->nullable();
// Indexes
$table->index(['name', 'country']);
$table->index('wikipedia_cached_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cities');
}
};
@@ -0,0 +1,43 @@
<?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('attractions', function (Blueprint $table) {
$table->id();
$table->foreignId('city_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->decimal('latitude', 10, 7);
$table->decimal('longitude', 10, 7);
$table->string('category')->nullable();
$table->enum('source', ['opentripmap', 'overpass', 'wikipedia']);
$table->string('source_id')->nullable();
$table->decimal('rating', 3, 2)->nullable();
$table->string('image_url')->nullable();
$table->timestamps();
$table->timestamp('cached_at')->nullable();
// Indexes
$table->index('city_id');
$table->index('category');
$table->index('cached_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attractions');
}
};
@@ -0,0 +1,33 @@
<?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('favorites', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('city_id')->constrained()->onDelete('cascade');
$table->timestamps();
// Indexes
$table->unique(['user_id', 'city_id']); // Prevent duplicate favorites
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('favorites');
}
};
@@ -0,0 +1,33 @@
<?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('trips', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
// Indexes
$table->index('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('trips');
}
};
@@ -0,0 +1,33 @@
<?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('city_trip', function (Blueprint $table) {
$table->id();
$table->foreignId('trip_id')->constrained()->onDelete('cascade');
$table->foreignId('city_id')->constrained()->onDelete('cascade');
$table->integer('order')->default(0); // Order of cities in trip
$table->timestamps();
// Indexes
$table->index(['trip_id', 'city_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('city_trip');
}
};
@@ -0,0 +1,50 @@
<?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::table('cities', function (Blueprint $table) {
$table->string('slug')->nullable()->after('name');
});
// Populate slugs for existing cities
$cities = DB::table('cities')->get();
foreach ($cities as $city) {
$slug = \Illuminate\Support\Str::slug($city->name);
$originalSlug = $slug;
$count = 1;
while (DB::table('cities')->where('slug', $slug)->where('id', '!=', $city->id)->exists()) {
$slug = $originalSlug . '-' . $count;
$count++;
}
DB::table('cities')->where('id', $city->id)->update(['slug' => $slug]);
}
// Now make it unique
Schema::table('cities', function (Blueprint $table) {
$table->string('slug')->unique()->change();
$table->index('slug');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cities', function (Blueprint $table) {
$table->dropIndex(['slug']);
$table->dropColumn('slug');
});
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Populate slugs for existing cities
$cities = DB::table('cities')->whereNull('slug')->orWhere('slug', '')->get();
foreach ($cities as $city) {
$slug = Str::slug($city->name);
$originalSlug = $slug;
$count = 1;
// Ensure uniqueness
while (DB::table('cities')->where('slug', $slug)->where('id', '!=', $city->id)->exists()) {
$slug = $originalSlug . '-' . $count;
$count++;
}
DB::table('cities')->where('id', $city->id)->update(['slug' => $slug]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// No need to reverse - slugs can stay
}
};
@@ -0,0 +1,28 @@
<?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::table('cities', function (Blueprint $table) {
$table->string('thumbnail_url')->nullable()->after('wikipedia_url');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cities', function (Blueprint $table) {
$table->dropColumn('thumbnail_url');
});
}
};
@@ -0,0 +1,28 @@
<?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::table('cities', function (Blueprint $table) {
$table->string('image_url')->nullable()->after('thumbnail_url');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('cities', function (Blueprint $table) {
$table->dropColumn('image_url');
});
}
};
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
+2182
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/vite": "^4.0.0",
"autoprefixer": "^10.4.27",
"axios": ">=1.11.0 <=1.14.0",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^3.0.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.2",
"vite": "^8.0.0"
},
"dependencies": {
"alpinejs": "^3.15.11"
}
}
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+25
View File
@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>
View File
+20
View File
@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+27
View File
@@ -0,0 +1,27 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
/* Custom animations */
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
}
+5
View File
@@ -0,0 +1,5 @@
import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
+4
View File
@@ -0,0 +1,4 @@
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
+129
View File
@@ -0,0 +1,129 @@
@extends('layouts.app')
@section('title', 'Login')
@section('content')
<div class="min-h-screen bg-gradient-to-br from-indigo-100 via-purple-50 to-pink-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full">
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-6">
<h2 class="text-3xl font-bold text-white text-center">Welcome Back</h2>
<p class="mt-2 text-center text-indigo-100">Sign in to your account</p>
</div>
<!-- Form -->
<div class="px-8 py-8">
<form method="POST" action="{{ route('login') }}" class="space-y-6">
@csrf
<!-- Email -->
<div>
<label for="email" class="block text-sm font-semibold text-gray-700 mb-2">Email Address</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
value="{{ old('email') }}"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 @error('email') border-red-500 @enderror"
placeholder="you@example.com"
>
@error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-semibold text-gray-700 mb-2">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 @error('password') border-red-500 @enderror"
placeholder="Enter your password"
>
@error('password')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember"
name="remember"
type="checkbox"
class="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
{{ old('remember') ? 'checked' : '' }}
>
<label for="remember" class="ml-2 block text-sm text-gray-700">
Remember me
</label>
</div>
<div class="text-sm">
<a href="#" class="font-medium text-purple-600 hover:text-purple-500 transition-colors duration-200">
Forgot password?
</a>
</div>
</div>
<!-- Error Messages -->
@if ($errors->any())
<div class="bg-red-50 border-l-4 border-red-500 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700 font-medium">{{ $errors->first() }}</p>
</div>
</div>
</div>
@endif
<!-- Submit Button -->
<div>
<button
type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-lg text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200 transform hover:-translate-y-0.5"
>
Sign In
</button>
</div>
</form>
<!-- Divider -->
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Don't have an account?</span>
</div>
</div>
</div>
<!-- Register Link -->
<div class="mt-6 text-center">
<a
href="{{ route('register') }}"
class="inline-flex items-center px-6 py-3 border-2 border-purple-200 rounded-xl text-sm font-semibold text-purple-600 hover:bg-purple-50 hover:border-purple-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200"
>
Create New Account
</a>
</div>
</div>
</div>
</div>
</div>
@endsection
+157
View File
@@ -0,0 +1,157 @@
@extends('layouts.app')
@section('title', 'Register')
@section('content')
<div class="min-h-screen bg-gradient-to-br from-indigo-100 via-purple-50 to-pink-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full">
<div class="bg-white rounded-2xl shadow-2xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-purple-600 to-pink-600 px-8 py-6">
<h2 class="text-3xl font-bold text-white text-center">Create Account</h2>
<p class="mt-2 text-center text-purple-100">Join Wiki Travel today</p>
</div>
<!-- Form -->
<div class="px-8 py-8">
<form method="POST" action="{{ route('register') }}" class="space-y-6">
@csrf
<!-- Name -->
<div>
<label for="name" class="block text-sm font-semibold text-gray-700 mb-2">Full Name</label>
<input
id="name"
name="name"
type="text"
autocomplete="name"
required
value="{{ old('name') }}"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 @error('name') border-red-500 @enderror"
placeholder="John Doe"
>
@error('name')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-semibold text-gray-700 mb-2">Email Address</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
value="{{ old('email') }}"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 @error('email') border-red-500 @enderror"
placeholder="you@example.com"
>
@error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-semibold text-gray-700 mb-2">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200 @error('password') border-red-500 @enderror"
placeholder="Min. 8 characters"
>
@error('password')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Confirm Password -->
<div>
<label for="password_confirmation" class="block text-sm font-semibold text-gray-700 mb-2">Confirm Password</label>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
autocomplete="new-password"
required
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Re-enter your password"
>
</div>
<!-- Terms & Conditions -->
<div class="flex items-start">
<div class="flex items-center h-5">
<input
id="terms"
name="terms"
type="checkbox"
required
class="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
>
</div>
<div class="ml-3 text-sm">
<label for="terms" class="text-gray-700">
I agree to the <a href="#" class="font-medium text-purple-600 hover:text-purple-500">Terms and Conditions</a> and <a href="#" class="font-medium text-purple-600 hover:text-purple-500">Privacy Policy</a>
</label>
</div>
</div>
<!-- Error Messages -->
@if ($errors->any())
<div class="bg-red-50 border-l-4 border-red-500 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-red-700 font-medium">{{ $errors->first() }}</p>
</div>
</div>
</div>
@endif
<!-- Submit Button -->
<div>
<button
type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-xl shadow-lg text-sm font-semibold text-white bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200 transform hover:-translate-y-0.5"
>
Create Account
</button>
</div>
</form>
<!-- Divider -->
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Already have an account?</span>
</div>
</div>
</div>
<!-- Login Link -->
<div class="mt-6 text-center">
<a
href="{{ route('login') }}"
class="inline-flex items-center px-6 py-3 border-2 border-purple-200 rounded-xl text-sm font-semibold text-purple-600 hover:bg-purple-50 hover:border-purple-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 transition-all duration-200"
>
Sign In Instead
</a>
</div>
</div>
</div>
</div>
</div>
@endsection
+397
View File
@@ -0,0 +1,397 @@
@extends('layouts.app')
@section('title', $city->name . ', ' . $city->country)
@section('content')
<!-- Hero Header -->
<div class="relative bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 text-white overflow-hidden">
@if($city->image_url)
<img src="{{ $city->image_url }}" alt="{{ $city->name }}" class="absolute inset-0 w-full h-full object-cover">
<div class="absolute inset-0 bg-black bg-opacity-40"></div>
@elseif($city->thumbnail_url)
<img src="{{ $city->thumbnail_url }}" alt="{{ $city->name }}" class="absolute inset-0 w-full h-full object-cover">
<div class="absolute inset-0 bg-black bg-opacity-40"></div>
@else
<div class="absolute inset-0 opacity-10">
<div class="absolute top-0 right-1/4 w-96 h-96 bg-white rounded-full mix-blend-overlay filter blur-3xl"></div>
</div>
@endif
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div class="flex items-center justify-between">
<div>
<h1 class="text-5xl md:text-6xl font-bold mb-3 drop-shadow-lg">{{ $city->name }}</h1>
<p class="text-2xl text-purple-100 mb-4 drop-shadow-md">{{ $city->country }}</p>
@if($city->wikipedia_url)
<a href="{{ $city->wikipedia_url }}" target="_blank" class="inline-flex items-center px-6 py-3 bg-white text-purple-600 rounded-lg font-semibold hover:bg-purple-50 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
<span>View on Wikipedia</span>
<svg class="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
@endif
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- City Summary -->
@if($city->wikipedia_summary)
<div class="bg-gradient-to-br from-white to-gray-50 rounded-2xl shadow-xl p-8 mb-12 border border-gray-100">
<div class="flex items-center mb-6">
<div class="w-1 h-8 bg-gradient-to-b from-purple-500 to-pink-500 rounded-full mr-4"></div>
<h2 class="text-3xl font-bold text-gray-900">About {{ $city->name }}</h2>
</div>
<p class="text-gray-700 text-lg leading-relaxed">{{ $city->wikipedia_summary }}</p>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Map -->
<div class="lg:col-span-2">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
<div class="px-6 py-5 bg-gradient-to-r from-purple-50 to-pink-50 border-b border-gray-200">
<div class="flex items-center">
<div class="w-1 h-6 bg-gradient-to-b from-purple-500 to-pink-500 rounded-full mr-3"></div>
<h2 class="text-2xl font-bold text-gray-900">Map & Attractions</h2>
</div>
</div>
<div
x-data="cityMap({{ $city->latitude }}, {{ $city->longitude }}, '{{ $city->slug }}')"
x-init="initMap()"
id="map"
class="h-[500px]"
></div>
</div>
</div>
<!-- Attractions List -->
<div class="lg:col-span-1">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
<div class="px-6 py-5 bg-gradient-to-r from-indigo-50 to-purple-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="w-1 h-6 bg-gradient-to-b from-indigo-500 to-purple-500 rounded-full mr-3"></div>
<h2 class="text-2xl font-bold text-gray-900">Attractions</h2>
</div>
<span class="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm font-semibold">{{ $city->attractions->count() }}</span>
</div>
</div>
<div x-data="attractionsList('{{ $city->slug }}')" x-init="loadAttractions()">
<!-- Category Filter -->
<div class="p-5 border-b border-gray-200 bg-gray-50">
<label class="block text-sm font-semibold text-gray-700 mb-2">Filter by Category</label>
<select
x-model="selectedCategory"
@change="filterAttractions()"
:disabled="loading"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed transition-all duration-200"
>
<option value="">All Categories</option>
<option value="museum">Museums</option>
<option value="restaurant">Restaurants</option>
<option value="historic">Historic</option>
<option value="park">Parks</option>
<option value="cultural">Cultural</option>
<option value="entertainment">Entertainment</option>
</select>
</div>
<!-- Attractions List -->
<div class="divide-y divide-gray-100 max-h-[500px] overflow-y-auto">
<template x-for="attraction in filteredAttractions" :key="attraction.id">
<div class="p-5 hover:bg-gradient-to-r hover:from-purple-50 hover:to-pink-50 cursor-pointer transition-all duration-200 group" @click="highlightAttraction(attraction)">
<h3 class="font-bold text-gray-900 group-hover:text-purple-600 transition-colors duration-200" x-text="attraction.name"></h3>
<div class="flex items-center gap-2 mt-2">
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-700 capitalize" x-text="attraction.category"></span>
<template x-if="attraction.rating">
<span class="inline-flex items-center text-sm font-semibold text-yellow-600">
★ <span x-text="attraction.rating" class="ml-0.5"></span>
</span>
</template>
</div>
</div>
</template>
<!-- Loading State -->
<div x-show="loading" class="p-12 text-center">
<div class="inline-flex flex-col items-center gap-3">
<svg class="animate-spin h-8 w-8 text-purple-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>
<span class="text-gray-600 font-medium">Loading attractions...</span>
</div>
</div>
<!-- Empty State -->
<div x-show="!loading && filteredAttractions.length === 0" class="p-12 text-center text-gray-500">
<div class="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full mb-4">
<svg class="h-8 w-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<p class="font-semibold text-gray-700 mb-1">No attractions found</p>
<p class="text-sm">Try selecting a different category</p>
</div>
<!-- Pagination Controls -->
<div x-show="!loading && pagination.last_page > 1" class="p-5 border-t border-gray-200 bg-gradient-to-r from-gray-50 to-purple-50">
<div class="flex items-center justify-between">
<button
@click="loadPage(pagination.current_page - 1)"
:disabled="pagination.current_page === 1"
class="px-4 py-2 text-sm font-semibold border-2 border-purple-200 text-purple-600 rounded-lg hover:bg-purple-50 hover:border-purple-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
Previous
</button>
<span class="text-sm font-semibold text-gray-700">
Page <span x-text="pagination.current_page" class="text-purple-600"></span> of <span x-text="pagination.last_page" class="text-purple-600"></span>
</span>
<button
@click="loadPage(pagination.current_page + 1)"
:disabled="pagination.current_page === pagination.last_page"
class="px-4 py-2 text-sm font-semibold border-2 border-purple-200 text-purple-600 rounded-lg hover:bg-purple-50 hover:border-purple-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
function cityMap(lat, lon, citySlug) {
return {
map: null,
markers: [],
allAttractions: [],
selectedCategory: '',
// Define colors and icons for each category
categoryColors: {
museum: '#8B4513', // Brown
restaurant: '#FF6347', // Tomato
historic: '#4169E1', // Royal Blue
park: '#228B22', // Forest Green
cultural: '#9370DB', // Medium Purple
entertainment: '#FF1493', // Deep Pink
other: '#808080' // Gray
},
categoryIcons: {
museum: '🏛️', // Museum emoji
restaurant: '🍽️', // Restaurant emoji
historic: '🏰', // Castle emoji
park: '🌳', // Tree emoji
cultural: '🎭', // Theater emoji
entertainment: '🎪', // Circus tent emoji
other: '📍' // Pin emoji
},
initMap() {
// Initialize Leaflet map
this.map = L.map('map').setView([lat, lon], 13);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(this.map);
// Add city marker (star icon for the city)
const cityIcon = L.divIcon({
html: '<div style="font-size: 24px;">⭐</div>',
className: 'city-marker',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
L.marker([lat, lon], { icon: cityIcon })
.addTo(this.map)
.bindPopup('<b>{{ $city->name }}</b>')
.openPopup();
// Load and display attractions
this.loadAttractions();
// Listen for category filter events
window.addEventListener('filter-category', (e) => {
this.filterAttractions(e.detail.category);
});
// Listen for highlight attraction events from the list
window.addEventListener('highlight-attraction', (e) => {
const attraction = e.detail.attraction;
// Find the corresponding marker
const marker = this.markers.find(m =>
Math.abs(m.getLatLng().lat - attraction.latitude) < 0.0001 &&
Math.abs(m.getLatLng().lng - attraction.longitude) < 0.0001
);
if (marker) {
this.map.setView([attraction.latitude, attraction.longitude], 16);
marker.openPopup();
}
});
},
createMarkerIcon(category) {
const icon = this.categoryIcons[category] || this.categoryIcons.other;
const color = this.categoryColors[category] || this.categoryColors.other;
return L.divIcon({
html: `<div style="font-size: 20px; background-color: white; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: 2px solid ${color}; box-shadow: 0 2px 6px rgba(0,0,0,0.3);">${icon}</div>`,
className: '',
iconSize: [28, 28],
iconAnchor: [14, 14],
popupAnchor: [0, -14]
});
},
async loadAttractions() {
try {
const response = await fetch(`/api/cities/${citySlug}/attractions?limit=100`);
const data = await response.json();
// Handle both paginated and non-paginated responses
this.allAttractions = data.data || data;
// Display all attractions on map
this.displayAttractions(this.allAttractions);
} catch (error) {
console.error('Error loading attractions:', error);
}
},
displayAttractions(attractions) {
// Clear existing markers
this.markers.forEach(marker => this.map.removeLayer(marker));
this.markers = [];
// Add new markers
attractions.forEach(attraction => {
const icon = this.createMarkerIcon(attraction.category);
const marker = L.marker([attraction.latitude, attraction.longitude], { icon })
.addTo(this.map)
.bindPopup(`
<div class="p-2">
<b>${attraction.name}</b><br>
<span class="text-sm capitalize" style="color: ${this.categoryColors[attraction.category] || this.categoryColors.other}">${attraction.category}</span>
${attraction.rating ? `<br><span class="text-yellow-600">★ ${attraction.rating}</span>` : ''}
</div>
`);
// Store category with marker for filtering
marker.category = attraction.category;
this.markers.push(marker);
});
},
filterAttractions(category) {
this.selectedCategory = category;
if (!category) {
// Show all markers
this.markers.forEach(marker => {
if (!this.map.hasLayer(marker)) {
marker.addTo(this.map);
}
});
} else {
// Show only markers matching the category
this.markers.forEach(marker => {
if (marker.category === category) {
if (!this.map.hasLayer(marker)) {
marker.addTo(this.map);
}
} else {
if (this.map.hasLayer(marker)) {
this.map.removeLayer(marker);
}
}
});
}
}
}
}
function attractionsList(citySlug) {
return {
attractions: [],
filteredAttractions: [],
selectedCategory: '',
loading: false,
currentPage: 1,
pagination: {
current_page: 1,
last_page: 1,
per_page: 20,
total: 0
},
async loadAttractions(page = 1) {
this.loading = true;
try {
let url = `/api/cities/${citySlug}/attractions?page=${page}`;
if (this.selectedCategory) {
url += `&category=${this.selectedCategory}`;
}
const response = await fetch(url);
const data = await response.json();
// Handle paginated response
if (data.data) {
this.filteredAttractions = data.data;
this.pagination = {
current_page: data.current_page || 1,
last_page: data.last_page || 1,
per_page: data.per_page || 20,
total: data.total || 0
};
} else {
// Handle non-paginated response (fallback)
this.filteredAttractions = data;
}
} catch (error) {
console.error('Error loading attractions:', error);
this.filteredAttractions = [];
} finally {
this.loading = false;
}
},
async filterAttractions() {
await this.loadAttractions(1);
// Notify the map to filter attractions
window.dispatchEvent(new CustomEvent('filter-category', {
detail: { category: this.selectedCategory }
}));
},
async loadPage(page) {
if (page >= 1 && page <= this.pagination.last_page) {
await this.loadAttractions(page);
}
},
highlightAttraction(attraction) {
// Dispatch event to highlight marker on map
window.dispatchEvent(new CustomEvent('highlight-attraction', {
detail: { attraction }
}));
}
}
}
</script>
@endpush
@endsection
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Wiki Travel Site') }} - @yield('title', 'Explore Cities')</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700" rel="stylesheet" />
<!-- Styles -->
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased bg-gray-50">
<!-- Navigation -->
<nav class="bg-white shadow-lg border-b border-gray-100 sticky top-0 z-50 backdrop-blur-sm bg-white/95">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20">
<div class="flex items-center">
<!-- Logo -->
<div class="flex-shrink-0 flex items-center">
<a href="/" class="flex items-center group">
<div class="w-10 h-10 bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 rounded-xl flex items-center justify-center mr-3 group-hover:scale-110 transition-transform duration-200">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<span class="text-2xl font-bold bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-500 bg-clip-text text-transparent">
Wiki Travel
</span>
</a>
</div>
<!-- Navigation Links -->
<div class="hidden sm:ml-10 sm:flex sm:space-x-8">
<a href="/" class="inline-flex items-center px-4 py-2 rounded-lg text-base font-semibold text-gray-700 hover:text-purple-600 hover:bg-purple-50 transition-all duration-200">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Home
</a>
</div>
</div>
<!-- Auth Links -->
<div class="flex items-center space-x-4">
@auth
<span class="text-sm text-gray-700 font-medium">Hello, {{ Auth::user()->name }}!</span>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="inline-flex items-center px-4 py-2 border-2 border-red-200 rounded-lg text-sm font-semibold text-red-600 hover:bg-red-50 hover:border-red-300 transition-all duration-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
Logout
</button>
</form>
@else
<a href="{{ route('login') }}" class="inline-flex items-center px-4 py-2 border-2 border-purple-200 rounded-lg text-sm font-semibold text-purple-600 hover:bg-purple-50 hover:border-purple-300 transition-all duration-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
Login
</a>
<a href="{{ route('register') }}" class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg text-sm font-semibold text-white hover:from-purple-700 hover:to-pink-700 shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
</svg>
Register
</a>
@endauth
</div>
</div>
</div>
</nav>
<!-- Page Content -->
<main>
@yield('content')
</main>
<!-- Footer -->
<footer class="bg-gradient-to-br from-gray-900 via-purple-900 to-indigo-900 text-white mt-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<!-- Brand Section -->
<div>
<div class="flex items-center mb-4">
<div class="w-10 h-10 bg-gradient-to-br from-pink-500 to-purple-500 rounded-xl flex items-center justify-center mr-3">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<span class="text-xl font-bold">Wiki Travel</span>
</div>
<p class="text-purple-200 text-sm leading-relaxed">
Explore cities around the world with comprehensive travel information, attractions, and interactive maps.
</p>
</div>
<!-- Quick Links -->
<div>
<h3 class="font-bold text-lg mb-4">Quick Links</h3>
<ul class="space-y-2">
<li>
<a href="/" class="text-purple-200 hover:text-white transition-colors duration-200 text-sm">Home</a>
</li>
<li>
<a href="/" class="text-purple-200 hover:text-white transition-colors duration-200 text-sm">Explore Cities</a>
</li>
</ul>
</div>
<!-- Data Sources -->
<div>
<h3 class="font-bold text-lg mb-4">Powered By</h3>
<div class="space-y-2 text-sm">
<div class="flex items-center text-purple-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Wikipedia
</div>
<div class="flex items-center text-purple-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
OpenTripMap
</div>
<div class="flex items-center text-purple-200">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
OpenStreetMap
</div>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="border-t border-purple-700 pt-8 mt-8">
<p class="text-center text-sm text-purple-300">
&copy; {{ date('Y') }} Wiki Travel Site. All rights reserved.
</p>
</div>
</div>
</footer>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
@stack('scripts')
</body>
</html>
+209
View File
@@ -0,0 +1,209 @@
@extends('layouts.app')
@section('title', 'Explore Cities Around the World')
@section('content')
<div class="relative">
<!-- Hero Section -->
<div class="relative bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 text-white overflow-hidden">
<!-- Animated background shapes -->
<div class="absolute inset-0 opacity-20">
<div class="absolute top-0 left-1/4 w-96 h-96 bg-white rounded-full mix-blend-overlay filter blur-3xl animate-pulse"></div>
<div class="absolute bottom-0 right-1/4 w-96 h-96 bg-yellow-300 rounded-full mix-blend-overlay filter blur-3xl animate-pulse" style="animation-delay: 1s;"></div>
</div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-32">
<div class="text-center">
<h1 class="text-5xl md:text-7xl font-bold mb-6 animate-fade-in-up">
Explore Cities Around the World
</h1>
<p class="text-xl md:text-2xl mb-12 text-purple-100 max-w-3xl mx-auto animate-fade-in-up" style="animation-delay: 0.2s;">
Discover hidden gems, plan unforgettable trips, and explore destinations like never before
</p>
<!-- City Search Component -->
<div x-data="citySearch()" class="max-w-2xl mx-auto animate-fade-in-up" style="animation-delay: 0.4s;">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-2xl blur opacity-75 group-hover:opacity-100 transition duration-1000 group-hover:duration-200"></div>
<input
type="text"
x-model="query"
@input.debounce.300ms="search()"
@focus="showResults = true"
placeholder="Search for a city..."
class="relative w-full px-8 py-5 text-lg text-gray-900 bg-white rounded-xl shadow-2xl focus:outline-none focus:ring-4 focus:ring-purple-300 transition-all duration-200"
>
<!-- Search Results Dropdown -->
<div
x-show="showResults && (results.local.length > 0 || results.wikipedia.length > 0)"
@click.away="showResults = false"
class="absolute z-10 w-full mt-2 bg-white rounded-lg shadow-xl max-h-96 overflow-y-auto"
>
<!-- Local Results -->
<template x-if="results.local.length > 0">
<div class="border-b border-gray-200">
<div class="px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-600 uppercase">
Saved Cities
</div>
<template x-for="city in results.local" :key="city.id">
<a
:href="`/cities/${city.slug}`"
class="block px-4 py-3 hover:bg-blue-50 cursor-pointer border-b border-gray-100"
>
<div class="font-semibold text-gray-900" x-text="city.name"></div>
<div class="text-sm text-gray-600" x-text="city.country"></div>
</a>
</template>
</div>
</template>
<!-- Wikipedia Results -->
<template x-if="results.wikipedia.length > 0">
<div>
<div class="px-4 py-2 bg-gray-50 text-xs font-semibold text-gray-600 uppercase">
Wikipedia Results
</div>
<template x-for="city in results.wikipedia" :key="city.title">
<div class="px-4 py-3 hover:bg-blue-50 cursor-pointer border-b border-gray-100">
<div class="font-semibold text-gray-900" x-text="city.title"></div>
<div class="text-sm text-gray-600" x-text="city.description"></div>
</div>
</template>
</div>
</template>
<!-- Loading State -->
<div x-show="loading" class="px-4 py-3 text-center text-gray-500">
<svg class="animate-spin h-5 w-5 mx-auto" 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>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Popular Destinations Section -->
<div class="bg-gray-50 py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-4xl font-bold text-gray-900 mb-4">Popular Destinations</h2>
<p class="text-xl text-gray-600">Start exploring these trending cities</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
@forelse($popularCities as $city)
<a href="{{ route('cities.show', $city->slug) }}" class="group relative overflow-hidden rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div class="relative h-64 overflow-hidden @if(!$city->thumbnail_url) bg-gradient-to-br from-blue-400 to-blue-600 @endif">
@if($city->thumbnail_url)
<img
src="{{ $city->thumbnail_url }}"
alt="{{ $city->name }}"
class="w-full h-full object-cover"
loading="lazy"
>
@endif
<div class="absolute inset-0 bg-black bg-opacity-30 group-hover:bg-opacity-50 transition-all duration-300"></div>
<div class="absolute inset-0 flex flex-col justify-end p-6 text-white">
<h3 class="text-2xl font-bold mb-1 drop-shadow-lg">{{ $city->name }}</h3>
<p class="text-sm text-gray-200 drop-shadow-md">{{ $city->country }}</p>
</div>
</div>
</a>
@empty
<!-- Fallback if no cities are available -->
<a href="#" class="group relative overflow-hidden rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div class="aspect-w-16 aspect-h-12 bg-gradient-to-br from-blue-400 to-blue-600 h-64">
<div class="absolute inset-0 bg-black bg-opacity-20 group-hover:bg-opacity-40 transition-all duration-300"></div>
<div class="absolute inset-0 flex flex-col justify-end p-6 text-white">
<h3 class="text-2xl font-bold mb-1">Start Exploring</h3>
<p class="text-sm text-gray-200">Search for cities above</p>
</div>
</div>
</a>
@endforelse
</div>
</div>
</div>
<!-- Features Section -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div class="text-center mb-12">
<h2 class="text-4xl font-bold text-gray-900 mb-4">Why Choose Wiki Travel</h2>
<p class="text-xl text-gray-600">Everything you need to plan your perfect journey</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="group text-center p-8 bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-400 to-blue-600 text-white rounded-2xl mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<h3 class="text-2xl font-bold mb-3 text-gray-900">Discover Cities</h3>
<p class="text-gray-600 leading-relaxed">Search and explore cities worldwide with comprehensive Wikipedia data and local insights</p>
</div>
<div class="group text-center p-8 bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-purple-400 to-pink-600 text-white rounded-2xl mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
<h3 class="text-2xl font-bold mb-3 text-gray-900">Find Attractions</h3>
<p class="text-gray-600 leading-relaxed">Explore museums, restaurants, historic sites, and countless points of interest around the globe</p>
</div>
<div class="group text-center p-8 bg-white rounded-2xl shadow-lg hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-2">
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-green-400 to-teal-600 text-white rounded-2xl mb-6 group-hover:scale-110 transition-transform duration-300">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
</div>
<h3 class="text-2xl font-bold mb-3 text-gray-900">Plan Trips</h3>
<p class="text-gray-600 leading-relaxed">Create detailed itineraries, save your favorite destinations, and organize the perfect journey</p>
</div>
</div>
</div>
</div>
@push('scripts')
<script>
function citySearch() {
return {
query: '',
results: { local: [], wikipedia: [] },
showResults: false,
loading: false,
async search() {
if (this.query.length < 2) {
this.results = { local: [], wikipedia: [] };
return;
}
this.loading = true;
try {
const response = await fetch(`/api/cities/search?q=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = {
local: data.local || [],
wikipedia: data.wikipedia || []
};
this.showResults = true;
} catch (error) {
console.error('Search error:', error);
} finally {
this.loading = false;
}
}
}
}
</script>
@endpush
@endsection
+48
View File
@@ -0,0 +1,48 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\API\CityController;
use App\Http\Controllers\API\FavoriteController;
use App\Http\Controllers\API\TripController;
use App\Http\Controllers\API\UserController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
*/
// Authentication routes (public)
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Public city routes (no authentication required)
Route::prefix('cities')->group(function () {
Route::get('/search', [CityController::class, 'search']);
Route::get('/{city}', [CityController::class, 'show']);
Route::get('/{city}/attractions', [CityController::class, 'attractions']);
Route::get('/{city}/map-data', [CityController::class, 'mapData']);
});
// Protected routes (require authentication)
Route::middleware('auth:sanctum')->group(function () {
// Auth routes
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
// User profile
Route::get('/profile', [UserController::class, 'show']);
Route::put('/profile', [UserController::class, 'update']);
// Cities (protected actions)
Route::post('/cities', [CityController::class, 'store']);
// Favorites
Route::get('/favorites', [FavoriteController::class, 'index']);
Route::post('/favorites/{city}', [FavoriteController::class, 'store']);
Route::delete('/favorites/{city}', [FavoriteController::class, 'destroy']);
// Trips
Route::apiResource('trips', TripController::class);
});
+8
View File
@@ -0,0 +1,8 @@
<?php
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+28
View File
@@ -0,0 +1,28 @@
<?php
use App\Http\Controllers\CityController;
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController;
use Illuminate\Support\Facades\Route;
// Homepage
Route::get('/', function () {
// Get popular cities with thumbnails
$popularCities = \App\Models\City::whereNotNull('thumbnail_url')
->inRandomOrder()
->limit(4)
->get(['id', 'name', 'slug', 'country', 'thumbnail_url']);
return view('welcome', compact('popularCities'));
});
// Authentication Routes
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('/login', [LoginController::class, 'login']);
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
Route::get('/register', [RegisterController::class, 'showRegistrationForm'])->name('register');
Route::post('/register', [RegisterController::class, 'register']);
// City pages (web views)
Route::get('/cities/{city}', [CityController::class, 'show'])->name('cities.show');
+4
View File
@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+9
View File
@@ -0,0 +1,9 @@
compiled.php
config.php
down
events.scanned.php
maintenance.php
routes.php
routes.scanned.php
schedule-*
services.json
+3
View File
@@ -0,0 +1,3 @@
*
!data/
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./resources/**/*.blade.php",
"./resources/**/*.js",
"./resources/**/*.vue",
],
theme: {
extend: {},
},
plugins: [],
}

Some files were not shown because too many files have changed in this diff Show More