Compare commits
28 Commits
c7fe3268c7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5850c849d0 | |||
| 05ca994253 | |||
| 6fad966b7e | |||
| badb4dc46f | |||
| 3aba428d2a | |||
| a270913931 | |||
| a753bffaf8 | |||
| e24d3ceaec | |||
| 30e4d5ffb3 | |||
| 9dbacaa4ac | |||
| 0f12250644 | |||
| a6812ad95c | |||
| 906f8cda57 | |||
| 09841ba1f7 | |||
| 61ff0c5002 | |||
| 05c16147ee | |||
| 57b015eb18 | |||
| 150c34bfb8 | |||
| 10d6ee8dee | |||
| 3eb3971d79 | |||
| 3bd2bda84c | |||
| 05a6d1da0e | |||
| f05ea2fd97 | |||
| 1846cb6a6d | |||
| e1bed676e4 | |||
| 10b5b6a5c9 | |||
| 1d5b9f340f | |||
| 69d72e0912 |
@@ -7,6 +7,8 @@ APP_URL=http://localhost
|
||||
APP_DOMAIN=flightsgoneby.test
|
||||
API_DOMAIN=api.flightsgoneby.test
|
||||
|
||||
TRUSTED_FRONTEND_ORIGINS=https://app.example.com
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\UserAction;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
#[Signature('app:flight-feed-update')]
|
||||
#[Description('Command description')]
|
||||
class FlightFeedUpdate extends Command
|
||||
{
|
||||
public function handle()
|
||||
{
|
||||
$time = now('UTC')->startOfMinute();
|
||||
|
||||
$this->logFlightActions('flight_departing', UserFlight::where('departure_date', $time)->get());
|
||||
$this->logFlightActions('flight_arriving', UserFlight::where('arrival_date', $time)->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @param Collection<UserFlight> $flights
|
||||
* @return void
|
||||
*/
|
||||
private function logFlightActions(string $type, Collection $flights): void
|
||||
{
|
||||
|
||||
foreach ($flights as $flight) {
|
||||
|
||||
UserAction::create([
|
||||
'user_id' => $flight->user_id,
|
||||
'type' => $type,
|
||||
'data' => [
|
||||
'flight' => $flight->snapshot($flight->id),
|
||||
]
|
||||
]);
|
||||
|
||||
if($type === 'flight_departing'){
|
||||
$flight->user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserFlight;
|
||||
use App\Services\FlightStatsService;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
@@ -19,80 +20,10 @@ use Illuminate\Support\Facades\Log;
|
||||
#[Description('Command description')]
|
||||
class UpdateDepartedFlights extends Command
|
||||
{
|
||||
/**
|
||||
* Fetch live flight data from FlightStats.
|
||||
* Returns null if the request fails or no data is found.
|
||||
*/
|
||||
protected function fetchFlightData(string $airlineCode, string $flightNumber, CarbonImmutable $date): ?FlightStatData
|
||||
|
||||
public function __construct(protected FlightStatsService $flightStats)
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/%s/%s/%d/%d/%d',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
$date->year,
|
||||
$date->month,
|
||||
$date->day,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::warning("FlightStats request failed for {$airlineCode}{$flightNumber}: HTTP {$response->status()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$flightData = $response->json('data');
|
||||
|
||||
if (empty($flightData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return FlightStatData::fromApiResponse($flightData);
|
||||
}
|
||||
/**
|
||||
* Attempt to resolve the best matching Aircraft record for a given IATA equipment code.
|
||||
* Prefers passenger variants over freighters/BBJs where multiple matches exist.
|
||||
*/
|
||||
protected function guessAircraftFromIata(string $iataCode): ?Aircraft
|
||||
{
|
||||
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
|
||||
|
||||
if (!$equipment) {
|
||||
Log::info("Unknown IATA equipment code: {$iataCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = Aircraft::where('designator', $equipment->icao_code)->get();
|
||||
|
||||
if ($candidates->isEmpty()) {
|
||||
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($candidates->count() === 1) {
|
||||
return $candidates->first();
|
||||
}
|
||||
|
||||
// Prefer passenger variants — deprioritise freighters, BBJs, and convertibles
|
||||
$deprioritised = ['freighter', 'bbj', 'combi', 'mixed', 'cargo', 'prestige', 'winglet', 'sharklet', 'freight'];
|
||||
|
||||
$pattern = implode('|', $deprioritised);
|
||||
|
||||
$passengerVariants = $candidates->filter(
|
||||
fn(Aircraft $a) => !preg_match("/({$pattern})/i", $a->display_name_short)
|
||||
);
|
||||
|
||||
if ($passengerVariants->count() === 1) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
if ($passengerVariants->isNotEmpty()) {
|
||||
return $passengerVariants->first();
|
||||
}
|
||||
|
||||
return $candidates->first();
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function notifyDataError(UserFlight $flight): void
|
||||
@@ -110,10 +41,9 @@ class UpdateDepartedFlights extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
$now = now()->utc();
|
||||
$oneHourAgo = $now->copy()->subHours(1);
|
||||
|
||||
$userFlights = UserFlight::where('arrival_date', '<=', $now->copy()->subHour()->toDateTimeString())
|
||||
->where('auto_update', true)
|
||||
@@ -136,7 +66,7 @@ class UpdateDepartedFlights extends Command
|
||||
|
||||
$arrivalDate = $flight->arrival_date->setTimezone($flight->arrivalAirport->timezone);
|
||||
|
||||
$data = $this->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
|
||||
$data = $this->flightStats->fetchFlightData($airlineCode, $flightNumber, $arrivalDate);
|
||||
|
||||
if (!$data) {
|
||||
$this->warn("No flight data returned for {$airlineCode}{$flightNumber}");
|
||||
@@ -170,7 +100,7 @@ class UpdateDepartedFlights extends Command
|
||||
$currentAircraft = $flight->aircraft;
|
||||
|
||||
if ($currentAircraft?->iata_code !== $data->equipment_iata) {
|
||||
$match = $this->guessAircraftFromIata($data->equipment_iata);
|
||||
$match = $this->flightStats->guessAircraftFromIata($data->equipment_iata);
|
||||
|
||||
if ($match) {
|
||||
$updates['aircraft_id'] = $match->id;
|
||||
@@ -203,6 +133,7 @@ class UpdateDepartedFlights extends Command
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Flight {$airlineCode}{$flightNumber} updated",
|
||||
'body' => implode("\n", $changeDescriptions),
|
||||
'url' => '/u/'. $flight->user->name . '/flight/'. $flight->id,
|
||||
]);
|
||||
} else {
|
||||
$this->info("No changes for {$airlineCode}{$flightNumber}");
|
||||
@@ -211,6 +142,7 @@ class UpdateDepartedFlights extends Command
|
||||
'user_id' => $flight->user_id,
|
||||
'title' => "Flight {$airlineCode}{$flightNumber} updated — no changes",
|
||||
'body' => "Your flight was completed and no updates were made to aircraft, registration, or departure/arrival times.",
|
||||
'url' => '/u/'. $flight->user->name . '/flight/'. $flight->id,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,22 @@ readonly class FlightStatData
|
||||
public ?string $equipment_iata,
|
||||
public ?string $departure_iata,
|
||||
public ?string $arrival_iata,
|
||||
public array $airline_fs_codes,
|
||||
public ?string $operating_fs,
|
||||
) {}
|
||||
|
||||
public static function fromApiResponse(array $flightData): self
|
||||
{
|
||||
$primaryFs = $flightData['resultHeader']['carrier']['fs'] ?? null;
|
||||
$operatingFs = $flightData['positional']['flexTrack']['carrierFsCode'] ?? null;
|
||||
$codeshares = array_column($flightData['codeshares'] ?? [], 'fs');
|
||||
|
||||
$fsCodes = array_values(array_unique(array_filter([
|
||||
$primaryFs,
|
||||
$operatingFs,
|
||||
...$codeshares,
|
||||
])));
|
||||
|
||||
return new self(
|
||||
aircraft_registration: $flightData['positional']['flexTrack']['tailNumber'] ?? null,
|
||||
estimated_departure_utc: isset($flightData['schedule']['estimatedActualDepartureUTC'])
|
||||
@@ -28,6 +40,8 @@ readonly class FlightStatData
|
||||
equipment_iata: $flightData['additionalFlightInfo']['equipment']['iata'] ?? null,
|
||||
departure_iata: $flightData['departureAirport']['iata'] ?? null,
|
||||
arrival_iata: $flightData['arrivalAirport']['iata'] ?? null,
|
||||
airline_fs_codes: $fsCodes,
|
||||
operating_fs: $flightData['positional']['flexTrack']['carrierFsCode'] ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\DTOs;
|
||||
|
||||
readonly class MissingLivery
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $airline_name,
|
||||
public readonly string $aircraft_display_name,
|
||||
public readonly string $filename,
|
||||
public readonly string $clipboard_text,
|
||||
) {}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AchievementController extends Controller
|
||||
{
|
||||
public function index(User $user)
|
||||
{
|
||||
$achievements = Achievement::with(['category', 'difficulty'])
|
||||
->get()
|
||||
->groupBy(fn(Achievement $a) => $a->category->name)
|
||||
->map(fn($group) => $group->sortBy('id')->values());
|
||||
|
||||
$userAchievements = $user->achievements()
|
||||
->with('achievement')
|
||||
->orderBy('achievement_id')
|
||||
->get()
|
||||
->keyBy('achievement_id');
|
||||
|
||||
return Inertia::render('UserAchievements', [
|
||||
'user' => $user,
|
||||
'canEdit' => auth()->id() === $user->id,
|
||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||
'achievements' => $achievements,
|
||||
'userAchievements' => $userAchievements,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
|
||||
use App\Models\IgnoredMissingLivery;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use App\Services\AdminService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
|
||||
public function __construct(private readonly AdminService $adminService){}
|
||||
|
||||
function staticAdminData(){
|
||||
return [
|
||||
'missingLiveryCount' => $this->adminService->getMissingLiveries()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
function dashboard(){
|
||||
return inertia('Admin/Dashboard', [
|
||||
'title' => 'Admin Control Panel',
|
||||
'userCount' => User::count(),
|
||||
'oneWeekUserGrowth' => User::where('created_at', '>=', Carbon::now()->subWeek())->count(),
|
||||
'flightCount' => UserFlight::count(),
|
||||
'oneWeekFlightGrowth' => UserFlight::where('created_at', '>=', Carbon::now()->subWeek())->count(),
|
||||
'latestUser' => User::latest()->first()->name,
|
||||
...$this->staticAdminData(),
|
||||
]);
|
||||
}
|
||||
|
||||
function reconcileMissingLiveries(){
|
||||
$missingLiveries = $this->adminService->getMissingLiveries();
|
||||
|
||||
return Inertia::render('Admin/MissingLiveries', [
|
||||
'title' => $missingLiveries->count() . ' Missing Liveries',
|
||||
'missingLiveries' => $missingLiveries,
|
||||
...$this->staticAdminData(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function ignoreMissingLivery(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'filename' => 'required|string',
|
||||
]);
|
||||
|
||||
IgnoredMissingLivery::createOrFirst($validated);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AdminToolsController extends Controller
|
||||
{
|
||||
function missingLiveries(){
|
||||
|
||||
|
||||
/* $existingFiles = collect(glob(public_path('img/liveries/generated/*')))
|
||||
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||
->toArray();*/
|
||||
|
||||
$existingFiles = collect(glob('C:\\Users\\josh\\WebstormProjects\\Watermark-Remover\\images\\liveries_processed\\*'))
|
||||
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||
->toArray();
|
||||
|
||||
|
||||
$combos = \App\Models\UserFlight::with(['aircraft', 'airline'])
|
||||
->select('airline_id', 'aircraft_id')
|
||||
->whereNotNull('airline_id')
|
||||
->whereNotNull('aircraft_id')
|
||||
->distinct()
|
||||
->get()
|
||||
->filter(fn ($flight) => $flight->aircraft && $flight->airline)
|
||||
->map(fn ($flight) => [
|
||||
'airline_name' => $flight->airline->name,
|
||||
'aircraft_display_name' => $flight->aircraft->display_name,
|
||||
'filename' => $flight->airline->internal_name . '_' . $flight->aircraft->designator,
|
||||
])
|
||||
->filter(fn ($combo) => !in_array($combo['filename'], $existingFiles))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'count' => $combos->count(),
|
||||
'liveries' => $combos,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AircraftApiController extends Controller
|
||||
{
|
||||
public function getLivery(string $aircraftDesignator){
|
||||
$path = "images/livery_templates/{$aircraftDesignator}.png";
|
||||
return $this->imageIfExists($path);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -47,6 +47,12 @@ class AirlineApiController extends ApiController
|
||||
return $this->getAirlineLogo($airline);
|
||||
}
|
||||
|
||||
public function getLivery(string $airlineInternalName, string $aircraftDesignator){
|
||||
$path = "images/liveries/{$airlineInternalName}_{$aircraftDesignator}.png";
|
||||
|
||||
return $this->imageIfExists($path);
|
||||
}
|
||||
|
||||
function parseAirlineData(Airline $airline){
|
||||
$countryCode = $airline->country->code;
|
||||
|
||||
@@ -62,7 +68,7 @@ class AirlineApiController extends ApiController
|
||||
}
|
||||
|
||||
function getByCode(string $code){
|
||||
$lookupColumn = strlen($code) === 3 ? 'ICAO_code' : 'IATA_code';
|
||||
$lookupColumn = strlen($code) === 3 ? 'icao_code' : 'iata_code';
|
||||
$airlines = Airline::where($lookupColumn, strtoupper($code))->get()->map(fn($airline) => $this->parseAirlineData($airline));
|
||||
return response()->json($airlines);
|
||||
|
||||
|
||||
@@ -3,21 +3,27 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Controllers\UserFlightController;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class UserApiController extends ApiController
|
||||
{
|
||||
public function nextFlight(string $username): JsonResponse
|
||||
public function nextFlight(User $user): JsonResponse
|
||||
{
|
||||
$user = User::where('name', 'ilike', $username)->first();
|
||||
|
||||
if (!$user) {
|
||||
if (!$user->id) {
|
||||
return response()->json(['message' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
if (Gate::denies('viewProfileData', $user)) {
|
||||
return response()->json(['message' => 'Cannot access private user.'], 403);
|
||||
}
|
||||
|
||||
$flight = UserFlight::with(['departureAirport', 'arrivalAirport', 'airline', 'aircraft'])
|
||||
->where('user_id', $user->id)
|
||||
->where('departure_date', '>', now()->utc())
|
||||
@@ -36,10 +42,12 @@ class UserApiController extends ApiController
|
||||
'departureCity' => $flight->departureAirport->municipality,
|
||||
'departureDateReadable' => $departure->format('F j'),
|
||||
'departureTime' => $departure->format('H:i'),
|
||||
'departureDateUtc' => $flight->departure_date,
|
||||
'arrivalAirportCode' => $flight->arrivalAirport->iata_code,
|
||||
'arrivalCity' => $flight->arrivalAirport->municipality,
|
||||
'arrivalDateReadable' => $arrival->format('F j'),
|
||||
'arrivalTime' => $arrival->format('H:i'),
|
||||
'arrivalDateUtc' => $flight->arrival_date,
|
||||
'flightNumber' => $flight->flight_number,
|
||||
'airlineName' => $flight->airline->name,
|
||||
'aircraftType' => $flight->aircraft->manufacturer_code . ' ' . $flight->aircraft->model_full_name,
|
||||
@@ -47,14 +55,20 @@ class UserApiController extends ApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function flights(string $username): JsonResponse
|
||||
public function viewableFlights(User $user)
|
||||
{
|
||||
$user = User::where('name', 'ilike', $username)->first();
|
||||
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'User not found'], 404);
|
||||
if (Gate::denies('viewProfileData', $user)) {
|
||||
return collect([]);
|
||||
}
|
||||
return $user->flightsWithRelationshipsLoaded();
|
||||
}
|
||||
|
||||
return response()->json($user->FlightController()->flights());
|
||||
public function viewableDepartedFlights(User $user)
|
||||
{
|
||||
if (Gate::denies('viewProfileData', $user)) {
|
||||
return collect([]);
|
||||
}
|
||||
return $user->flightsWithRelationshipsLoaded('departed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
return redirect()->intended(route('home', absolute: false));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,23 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
public function imageIfExists(string $path, $cacheLimit = 60 * 60 * 72){
|
||||
if(!Storage::disk('local')->exists($path)){
|
||||
return response()->json(['error' => 'Image not found'], 404);
|
||||
}
|
||||
|
||||
$fullPath = Storage::disk('local')->path($path);
|
||||
$lastModified = filemtime($fullPath);
|
||||
|
||||
return response()->file($fullPath, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Cache-Control' => 'public, max-age='.$cacheLimit, // 24 hours
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
|
||||
'ETag' => md5($path . $lastModified),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Models\Airport;
|
||||
use App\Models\CrewType;
|
||||
use App\Models\FlightClass;
|
||||
use App\Models\FlightReason;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\SeatType;
|
||||
use App\Models\UserAction;
|
||||
use App\Models\UserFlight;
|
||||
use App\Services\FlightStatsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -24,7 +26,24 @@ class FlightController extends Controller
|
||||
return [
|
||||
'flight_number' => ['nullable', 'string', 'max:10'],
|
||||
'departure_date' => ['required', 'date'],
|
||||
'arrival_date' => ['required', 'date'],
|
||||
'arrival_date' => ['required', 'date', function ($attribute, $value, $fail) {
|
||||
$from = request()->input('from_id');
|
||||
$to = request()->input('to_id');
|
||||
|
||||
if (!$from || !$to) return;
|
||||
|
||||
$departureAirport = Airport::find($from);
|
||||
$arrivalAirport = Airport::find($to);
|
||||
|
||||
if (!$departureAirport || !$arrivalAirport) return;
|
||||
|
||||
$departureUtc = Carbon::createFromFormat('Y-m-d\TH:i', request()->input('departure_date'), $departureAirport->timezone)->utc();
|
||||
$arrivalUtc = Carbon::createFromFormat('Y-m-d\TH:i', $value, $arrivalAirport->timezone)->utc();
|
||||
|
||||
if ($arrivalUtc->lessThanOrEqualTo($departureUtc)) {
|
||||
$fail('The arrival time must be after the departure time, accounting for time zones.');
|
||||
}
|
||||
}],
|
||||
'from_id' => ['required', 'integer', 'exists:airports,id'],
|
||||
'to_id' => ['required', 'integer', 'exists:airports,id'],
|
||||
'airline_id' => ['nullable', 'integer', 'exists:airlines,id'],
|
||||
@@ -44,24 +63,74 @@ class FlightController extends Controller
|
||||
{
|
||||
$number = strtoupper(trim($request->query('number', '')));
|
||||
|
||||
// Extract the airline code prefix — letters at the start e.g. "QF" from "QF1"
|
||||
preg_match('/^([A-Z]{2,3})/', $number, $matches);
|
||||
preg_match('/^([A-Z]{2,3})(\d+)/', $number, $matches);
|
||||
$code = $matches[1] ?? null;
|
||||
$flightNumber = $matches[2] ?? null;
|
||||
$isIata = strlen($code) === 2;
|
||||
$codeColumn = $isIata ? 'IATA_code' : 'ICAO_code';
|
||||
$codeColumn = $isIata ? 'iata_code' : 'icao_code';
|
||||
|
||||
$airlines = $code
|
||||
? Airline::where($codeColumn, $code)
|
||||
$apiAirlineCodes = [];
|
||||
$fromOptions = [];
|
||||
$toOptions = [];
|
||||
$aircraftOptions = [];
|
||||
|
||||
if (strlen($number) >= 3 && $isIata) {
|
||||
$flightStatsApi = new FlightStatsService();
|
||||
$flightData = $flightStatsApi->fetchFlightData($code, $flightNumber);
|
||||
|
||||
if ($flightData) {
|
||||
$apiAirlineCodes = $flightData->airline_fs_codes;
|
||||
|
||||
$fromOptions = Airport::where('iata_code', $flightData->departure_iata)
|
||||
->get()
|
||||
->map(fn ($airline) => ['value' => $airline->id, 'title' => $airline->display_name, 'logo_url' => $airline->logo_url])
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
|
||||
->values()
|
||||
->toArray()
|
||||
: collect()->toArray();
|
||||
->toArray();
|
||||
|
||||
$toOptions = Airport::where('iata_code', $flightData->arrival_iata)
|
||||
->get()
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'country_code' => strtolower($a->region->country->code)])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if($flightData->equipment_iata){
|
||||
$equipment = IataEquipmentCode::where('iata_code', $flightData->equipment_iata)->first();
|
||||
if ($equipment) {
|
||||
$bestGuess = $flightStatsApi->guessAircraftFromIata($flightData->equipment_iata);
|
||||
|
||||
$aircraftOptions = Aircraft::where('designator', $equipment->icao_code)
|
||||
->get()
|
||||
->sortBy(fn($a) => $a->id === $bestGuess?->id ? 0 : 1)
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Airlines from the typed code + any additional codes from the API, merged and deduped by id
|
||||
$allCodes = array_unique(array_filter([$code, ...$apiAirlineCodes]));
|
||||
|
||||
$airlines = Airline::where(function ($q) use ($codeColumn, $code, $allCodes) {
|
||||
$q->whereIn($codeColumn, $allCodes);
|
||||
})
|
||||
->get()
|
||||
->unique('id')
|
||||
->sortBy(function ($a) use ($code, $flightData) {
|
||||
if ($a->iata_code === $flightData?->operating_fs) return 0;
|
||||
if ($a->iata_code === $code) return 1;
|
||||
return 2;
|
||||
})
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name, 'logo_url' => $a->logo_url])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
return response()->json([
|
||||
'airline_options' => $airlines,
|
||||
'from_options' => [],
|
||||
'to_options' => [],
|
||||
'aircraft_options' => [],
|
||||
'from_options' => $fromOptions,
|
||||
'to_options' => $toOptions,
|
||||
'aircraft_options' => $aircraftOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -203,10 +272,10 @@ class FlightController extends Controller
|
||||
$updated = $flight->snapshot($flight->id);
|
||||
$this->recordChanges($flight, $dirty, $original, $updated);
|
||||
|
||||
return redirect()->route('profile.departure-board', [Auth::user()->name, $flight->id]);
|
||||
return redirect()->route('profile.departure-board', [$flight->user->name, $flight->id]);
|
||||
}
|
||||
|
||||
public function delete(UserFlight $flight)
|
||||
public function delete(UserFlight $flight, ?string $referrer = 'departure-board')
|
||||
{
|
||||
$this->authorize('delete', $flight);
|
||||
|
||||
@@ -227,7 +296,7 @@ class FlightController extends Controller
|
||||
]);
|
||||
|
||||
$flight->delete();
|
||||
return redirect()->route('profile.departure-board', [Auth::user()->name]);
|
||||
return redirect()->route('profile.'.$referrer, [Auth::user()->name]);
|
||||
}
|
||||
|
||||
public function staticData() : array {
|
||||
|
||||
@@ -35,37 +35,21 @@ class FlightImportController extends Controller
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getPossibleAircraft(string $aircraftQuery) {
|
||||
public function getPossibleAircraft(string $aircraftQuery): array
|
||||
{
|
||||
preg_match('/\((\w+)\)/', $aircraftQuery, $matches);
|
||||
$designator = $matches[1] ?? null;
|
||||
|
||||
$sortOverrides = [
|
||||
'B788' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
|
||||
'B789' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END",
|
||||
];
|
||||
if (!$designator) return [];
|
||||
|
||||
if(!$designator){
|
||||
$aircraft = [];
|
||||
} else {
|
||||
|
||||
$aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator))
|
||||
->when(
|
||||
isset($sortOverrides[$designator]),
|
||||
fn($q) => $q->orderByRaw($sortOverrides[$designator])
|
||||
)
|
||||
return Aircraft::where('designator', 'ilike', $designator)
|
||||
->orderByDesc('preferred')
|
||||
->orderBy('model_full_name')
|
||||
->limit(10)
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
|
||||
->map(fn($aircraft) => [
|
||||
'value' => $aircraft->id,
|
||||
'title' => $aircraft->display_name,
|
||||
])
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator', 'preferred'])
|
||||
->map(fn($a) => ['value' => $a->id, 'title' => $a->display_name])
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
}
|
||||
|
||||
return $aircraft;
|
||||
}
|
||||
|
||||
public function getPossibleAirports(string $airportQuery) {
|
||||
@@ -116,20 +100,20 @@ class FlightImportController extends Controller
|
||||
$airlines = Airline::when($iata || $icao, function ($query) use ($iata, $icao) {
|
||||
$query->orderByRaw("
|
||||
CASE
|
||||
WHEN \"IATA_code\" = ? AND \"ICAO_code\" = ? THEN 0
|
||||
WHEN \"IATA_code\" = ? THEN 1
|
||||
WHEN \"ICAO_code\" = ? THEN 2
|
||||
WHEN \"iata_code\" = ? AND \"icao_code\" = ? THEN 0
|
||||
WHEN \"iata_code\" = ? THEN 1
|
||||
WHEN \"icao_code\" = ? THEN 2
|
||||
ELSE 3
|
||||
END
|
||||
", [$iata, $icao, $iata, $icao])
|
||||
->where(function ($q) use ($iata, $icao) {
|
||||
$q->where('IATA_code', $iata)
|
||||
->orWhere('ICAO_code', $icao);
|
||||
$q->where('iata_code', $iata)
|
||||
->orWhere('icao_code', $icao);
|
||||
});
|
||||
})
|
||||
->orderByDesc('active')
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'IATA_code', 'ICAO_code', 'internal_name'])
|
||||
->get(['id', 'name', 'iata_code', 'icao_code', 'internal_name'])
|
||||
->map(fn($airline) => [
|
||||
'value' => $airline->id,
|
||||
'title' => $airline->display_name,
|
||||
@@ -142,6 +126,18 @@ class FlightImportController extends Controller
|
||||
return $airlines;
|
||||
}
|
||||
|
||||
|
||||
public function showFr24Import()
|
||||
{
|
||||
if (Auth::user()->importedFlights()->exists()) {
|
||||
return to_route('reconcile');
|
||||
}
|
||||
|
||||
return Inertia::render('Fr24Import');
|
||||
}
|
||||
|
||||
// FlightImportController.php
|
||||
|
||||
public function reconcile(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
@@ -149,16 +145,14 @@ class FlightImportController extends Controller
|
||||
$flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('date', 'asc')->first();
|
||||
|
||||
if (!$flightToReconcile) {
|
||||
return null;
|
||||
return to_route('import.fr24');
|
||||
}
|
||||
|
||||
$date = null;
|
||||
if ($flightToReconcile->date) {
|
||||
$date = Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d');
|
||||
}
|
||||
$date = $flightToReconcile->date
|
||||
? Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d')
|
||||
: null;
|
||||
|
||||
|
||||
return [
|
||||
$flight = [
|
||||
'imported_flight_id' => $flightToReconcile->id,
|
||||
'flight_classes' => $this->selectOptions(FlightClass::class),
|
||||
'flight_reasons' => $this->selectOptions(FlightReason::class),
|
||||
@@ -170,15 +164,19 @@ class FlightImportController extends Controller
|
||||
'duration' => $this->formatTime($flightToReconcile->duration),
|
||||
'registration' => $flightToReconcile->registration ?? '',
|
||||
'note' => $flightToReconcile->note ?? '',
|
||||
'seat_number' => $flightToReconcile->seat_number ?? '',
|
||||
'flight_class' => $flightToReconcile->flight_class ?? '',
|
||||
'seat_type' => $flightToReconcile->seat_type ?? '',
|
||||
'flight_reason' => $flightToReconcile->flight_reason ?? '',
|
||||
'flight_class' => $flightToReconcile->flight_class !== null ? (int) $flightToReconcile->flight_class : null,
|
||||
'seat_type' => $flightToReconcile->seat_type !== null ? (int) $flightToReconcile->seat_type : null,
|
||||
'flight_reason' => $flightToReconcile->flight_reason !== null ? (int) $flightToReconcile->flight_reason : null,
|
||||
'airline_options' => $this->getPossibleAirlines($flightToReconcile->airline ?? ''),
|
||||
'to_options' => $this->getPossibleAirports($flightToReconcile->to ?? ''),
|
||||
'from_options' => $this->getPossibleAirports($flightToReconcile->from ?? ''),
|
||||
'aircraft_options' => $this->getPossibleAircraft($flightToReconcile->aircraft ?? ''),
|
||||
];
|
||||
|
||||
return Inertia::render('ReconcileFlight', [
|
||||
'flight' => $flight,
|
||||
'key' => $flight['imported_flight_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function save(Request $request)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use App\Http\Resources\UserFlightResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class FlightProfileController extends Controller
|
||||
{
|
||||
public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array {
|
||||
return [
|
||||
'user' => $user,
|
||||
'canEdit' => auth()->check() && auth()->id() === $user->id,
|
||||
'initialView' => $view,
|
||||
'selectedFlightId' => $selectedFlightId,
|
||||
'flight_api_url' => '/data/user/'.$user->name.'/flights',
|
||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||
];
|
||||
}
|
||||
|
||||
public function departureBoard(User $user, ?UserFlight $flight = null){
|
||||
$profileData = $this->profileData($user, 'board', $flight?->id);
|
||||
return Inertia::render('UserProfile', $profileData);
|
||||
}
|
||||
|
||||
public function map(User $user){
|
||||
$profileData = $this->profileData($user, 'map');
|
||||
return Inertia::render('UserProfile', $profileData);
|
||||
}
|
||||
|
||||
public function boardingPasses(User $user){
|
||||
$profileData = $this->profileData($user, 'passes');
|
||||
return Inertia::render('UserProfile', $profileData);
|
||||
}
|
||||
|
||||
public function view(User $user)
|
||||
{
|
||||
return $this->departureBoard($user);
|
||||
}
|
||||
|
||||
public function flight(User $user, UserFlight $userFlight)
|
||||
{
|
||||
if($userFlight->user_id !== $user->id){
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
return Inertia::render('UserFlight', [
|
||||
'flightCount' => $user->flights()->count(),
|
||||
'flight' => $userFlight->snapshot($userFlight->id),
|
||||
'user' => $user,
|
||||
'isFollowing' => auth()->check() && auth()->user()->isFollowing($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Followee;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FollowerController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$followers = Followee::with('user')
|
||||
->where('followee_id', auth()->id())
|
||||
->orderBy('verified') // unverified first
|
||||
->get()
|
||||
->map(fn (Followee $f) => [
|
||||
'user' => $f->user,
|
||||
'verified' => $f->verified,
|
||||
]);
|
||||
|
||||
return response()->json($followers);
|
||||
}
|
||||
|
||||
public function approve(User $follower): JsonResponse
|
||||
{
|
||||
$followee = Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->pending()
|
||||
->firstOrFail();
|
||||
|
||||
$followee->update(['verified' => true]);
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $follower->id,
|
||||
'title' => 'Follow request accepted',
|
||||
'body' => auth()->user()->name . ' accepted your follow request.',
|
||||
'is_achievement' => false,
|
||||
'url' => '/u/' . auth()->user()->name,
|
||||
]);
|
||||
|
||||
return response()->json(['status' => 'approved']);
|
||||
}
|
||||
|
||||
public function deny(User $follower): JsonResponse
|
||||
{
|
||||
Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->pending()
|
||||
->delete();
|
||||
|
||||
return response()->json(['status' => 'denied']);
|
||||
}
|
||||
|
||||
public function remove(User $follower): JsonResponse
|
||||
{
|
||||
Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->verified()
|
||||
->delete();
|
||||
|
||||
return response()->json(['status' => 'removed']);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,13 @@ class SearchController extends Controller
|
||||
->where(function ($query) use ($q) {
|
||||
$len = strlen($q);
|
||||
if ($len === 2) {
|
||||
$query->where('IATA_code', 'ilike', $q);
|
||||
$query->where('iata_code', 'ilike', $q);
|
||||
} elseif ($len === 3) {
|
||||
$query->where('ICAO_code', 'ilike', $q);
|
||||
$query->where('icao_code', 'ilike', $q);
|
||||
} else {
|
||||
$query->where('name', 'ilike', "%{$q}%")
|
||||
->orWhere('IATA_code', 'ilike', "%{$q}%")
|
||||
->orWhere('ICAO_code', 'ilike', "%{$q}%");
|
||||
->orWhere('iata_code', 'ilike', "%{$q}%")
|
||||
->orWhere('icao_code', 'ilike', "%{$q}%");
|
||||
}
|
||||
})
|
||||
->limit(50)
|
||||
@@ -44,7 +44,7 @@ class SearchController extends Controller
|
||||
->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$q}%"])
|
||||
->orWhereRaw("CONCAT(manufacturer_code, ' ', model_full_name) ilike ?", ["%{$replacedQuery}%"])
|
||||
->limit(200)
|
||||
->orderBy('id', 'asc')
|
||||
->orderBy('preferred', 'desc')
|
||||
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
|
||||
->map(fn($aircraft) => [
|
||||
'value' => $aircraft->id,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Settings\SettingsRegistry;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function show(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []);
|
||||
$schema = SettingsRegistry::schema();
|
||||
|
||||
$fields = array_map(fn($field) => array_merge($field, [
|
||||
'value' => $current[$field['key']] ?? $field['default'],
|
||||
]), $schema);
|
||||
|
||||
return response()->json(['fields' => $fields]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validated = $request->validate(SettingsRegistry::validationRules());
|
||||
$request->user()->updateSettings($validated['settings']);
|
||||
return response()->json(['message' => 'Settings saved.']);
|
||||
}
|
||||
|
||||
public function updateSingle(Request $request, string $key)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'value' => ['required'],
|
||||
]);
|
||||
|
||||
$request->user()->updateSetting($key, $validated['value']);
|
||||
|
||||
return response()->json(['message' => 'Setting saved.']);
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,93 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Followee;
|
||||
use App\Models\Notification;
|
||||
use App\Models\User;
|
||||
use App\Settings\SettingsRegistry;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function follow(User $user): JsonResponse
|
||||
{
|
||||
abort_if($user->id === auth()->id(), 403);
|
||||
|
||||
$existing = Followee::where('user_id', auth()->id())
|
||||
->where('followee_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
$existing->delete();
|
||||
return response()->json(['following' => false]);
|
||||
return response()->json(['status' => 'none']);
|
||||
}
|
||||
|
||||
$canView = Gate::allows('viewProfileData', $user);
|
||||
|
||||
Followee::create([
|
||||
'user_id' => auth()->id(),
|
||||
'followee_id' => $user->id,
|
||||
'verified' => $canView,
|
||||
]);
|
||||
|
||||
return response()->json(['following' => true]);
|
||||
Notification::create([
|
||||
'user_id' => $user->id,
|
||||
'title' => $canView ? 'New follower' : 'Follow request',
|
||||
'body' => $canView
|
||||
? auth()->user()->name . ' is now following you.'
|
||||
: auth()->user()->name . ' wants to follow you.',
|
||||
'is_achievement' => false,
|
||||
'url' => $canView ? '/u/' . auth()->user()->name : '/follow-requests',
|
||||
]);
|
||||
|
||||
return response()->json(['status' => $canView ? 'following' : 'requested']);
|
||||
}
|
||||
|
||||
public function approveRequest(User $follower): JsonResponse
|
||||
{
|
||||
$followee = Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->pending()
|
||||
->firstOrFail();
|
||||
|
||||
$followee->update(['verified' => true]);
|
||||
|
||||
Notification::create([
|
||||
'user_id' => $follower->id,
|
||||
'title' => 'Follow request accepted',
|
||||
'body' => auth()->user()->name . ' accepted your follow request.',
|
||||
'is_achievement' => false,
|
||||
'url' => '/u/' . auth()->user()->name,
|
||||
]);
|
||||
|
||||
return response()->json(['approved' => true]);
|
||||
}
|
||||
|
||||
public function denyRequest(User $follower): JsonResponse
|
||||
{
|
||||
Followee::where('user_id', $follower->id)
|
||||
->where('followee_id', auth()->id())
|
||||
->pending()
|
||||
->delete();
|
||||
|
||||
return response()->json(['denied' => true]);
|
||||
}
|
||||
|
||||
public function settings(?string $category = null){
|
||||
$allowedTabs = ['general', 'followers'];
|
||||
$user = auth()->user();
|
||||
$current = array_merge(SettingsRegistry::defaults(), $user->settings ?? []);
|
||||
$fields = array_map(fn($field) => array_merge($field, [
|
||||
'value' => $current[$field['key']] ?? $field['default'],
|
||||
]), SettingsRegistry::schema());
|
||||
|
||||
return Inertia::render('UserSettings', [
|
||||
'fields' => $fields,
|
||||
'categories' => SettingsRegistry::categories(),
|
||||
'defaultTab' => in_array($category, $allowedTabs, true) ? $category : 'general',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,32 +5,11 @@ namespace App\Http\Controllers;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class UserFlightController extends Controller
|
||||
{
|
||||
|
||||
protected User $user;
|
||||
|
||||
function __construct(User $user){
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function flights(){
|
||||
return UserFlight::where('user_id', $this->user->id)
|
||||
->with([
|
||||
'departureAirport.region.country',
|
||||
'departureAirport.region.continent',
|
||||
'arrivalAirport.region.country',
|
||||
'arrivalAirport.region.continent',
|
||||
'airline.country',
|
||||
'airline.alliance',
|
||||
'aircraft',
|
||||
'seatType',
|
||||
'flightReason',
|
||||
'flightClass',
|
||||
'crewType'
|
||||
])
|
||||
->orderBy('departure_date', 'desc')
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Alliance;
|
||||
use App\Models\Continent;
|
||||
use App\Models\Country;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use App\Http\Resources\UserFlightResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UserProfileController extends Controller
|
||||
{
|
||||
public function index(){
|
||||
if (auth()->check()) {
|
||||
$user = auth()->user();
|
||||
$defaultPage = $user->resolved_settings['default_login_page'];
|
||||
|
||||
$route = match ($defaultPage) {
|
||||
'feed_first' => $user->following()->count() > 0 ? 'feed' : 'profile.view',
|
||||
'feed' => 'feed',
|
||||
'profile' => 'profile.view',
|
||||
'dashboard' => 'dashboard',
|
||||
};
|
||||
|
||||
$args = $route == 'profile.view' ? $user->name : null;
|
||||
|
||||
return redirect()->route($route, $args);
|
||||
}
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
public static function getUserFlightApiURL(User $user){
|
||||
return config('app.logo_api_url').'/user/'.$user->name.'/flights';
|
||||
//return '/data/user/'.$user->name.'/flights';
|
||||
}
|
||||
|
||||
public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array {
|
||||
return [
|
||||
'user' => $user,
|
||||
'canView' => Gate::allows('viewProfileData', $user),
|
||||
'canEdit' => auth()->check() && (auth()->id() === $user->id || auth()->user()->hasRole('admin')),
|
||||
'initialView' => $view,
|
||||
'selectedFlightId' => $selectedFlightId,
|
||||
'flight_api_url' => self::getUserFlightApiURL($user),
|
||||
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||
'flightCount' => $user->departedFlights()->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function departureBoard(User $user, ?UserFlight $flight = null){
|
||||
$profileData = $this->profileData($user, 'board', $flight?->id);
|
||||
return Inertia::render('UserProfile', $profileData);
|
||||
}
|
||||
|
||||
public function map(User $user){
|
||||
$profileData = $this->profileData($user, 'map');
|
||||
return Inertia::render('UserProfile', $profileData);
|
||||
}
|
||||
|
||||
public function boardingPasses(User $user){
|
||||
$profileData = $this->profileData($user, 'passes');
|
||||
return Inertia::render('UserProfile', $profileData);
|
||||
}
|
||||
|
||||
public function view(User $user, ?string $page = null)
|
||||
{
|
||||
$loggedInUser = auth()->user();
|
||||
$defaultView = $page ?: ($loggedInUser ? $loggedInUser->resolved_settings['default_profile_view'] : 'map');
|
||||
|
||||
return match($defaultView) {
|
||||
'boarding-passes' => $this->boardingPasses($user),
|
||||
'map' => $this->map($user),
|
||||
'departure-board' => $this->departureBoard($user),
|
||||
'achievements' => redirect()->route('profile.achievements', $user->name),
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
public function flight(User $user, UserFlight $userFlight)
|
||||
{
|
||||
if($userFlight->user_id !== $user->id){
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return Inertia::render('UserFlight', [
|
||||
'flightCount' => $user->departedFlights()->count(),
|
||||
'flight' => $userFlight->snapshot($userFlight->id),
|
||||
'canEdit' => auth()->check() && auth()->id() === $user->id,
|
||||
'canView' => Gate::allows('viewProfileData', $user),
|
||||
'user' => $user,
|
||||
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function achievements(User $user)
|
||||
{
|
||||
$canView = Gate::allows('viewProfileData', $user);
|
||||
|
||||
$achievements = Achievement::with(['category', 'difficulty'])
|
||||
->get()
|
||||
->groupBy(fn(Achievement $a) => $a->category->name)
|
||||
->map(fn($group) => $group->sortBy('sort_order')->values());
|
||||
|
||||
$userAchievements = $user->achievements()
|
||||
->with('achievement')
|
||||
->select(['achievement_id', 'progress'])
|
||||
->orderBy('achievement_id')
|
||||
->get()
|
||||
->keyBy('achievement_id');
|
||||
|
||||
$unlockedByCategory = $achievements->map(fn($group) =>
|
||||
$group->filter(fn($a) => $userAchievements->get($a->id)?->unlocked)->count()
|
||||
);
|
||||
|
||||
$unlockedCount = $userAchievements->filter(fn($ua) => $ua->unlocked)->count();
|
||||
return Inertia::render('UserAchievements', [
|
||||
'canView' => $canView,
|
||||
'user' => $user,
|
||||
'canEdit' => auth()->id() === $user->id,
|
||||
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||
'achievements' => $canView ? $achievements : [],
|
||||
'userAchievements' => $canView ? $userAchievements : [],
|
||||
'loggedInUser' => auth()->user(),
|
||||
'unlockedCount' => $unlockedCount,
|
||||
'unlockedByCategory' => $unlockedByCategory,
|
||||
'totalAchievements' => $achievements->flatten()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function achievement(User $user, Achievement $achievement)
|
||||
{
|
||||
$regions = match($achievement->internal_name){
|
||||
'fun_challenges.australian_states' => Country::whereCode('AU')->first()->sortedRegions(),
|
||||
'fun_challenges.chinese_provinces' => Country::whereCode('CN')->first()->sortedRegions(),
|
||||
'fun_challenges.canadian_provinces' => Country::whereCode('CA')->first()->sortedRegions(),
|
||||
'fun_challenges.brazilian_states' => Country::whereCode('BR')->first()->sortedRegions(),
|
||||
'fun_challenges.us_states' => Country::whereCode('US')->first()->sortedRegions(),
|
||||
default => [],
|
||||
};
|
||||
|
||||
$allianceInternalName = match($achievement->internal_name){
|
||||
'airlines_alliances.all_star_alliance' => 'star_alliance',
|
||||
'airlines_alliances.all_oneworld' => 'oneworld',
|
||||
'airlines_alliances.all_skyteam' => 'skyteam',
|
||||
'airlines_alliances.all_vanilla_alliance' => 'vanilla_alliance',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$continents = match($achievement->internal_name){
|
||||
'countries_continents.all_continent_pairs_one_way', 'countries_continents.all_continent_pairs_both_ways' => Continent::all()->toArray(),
|
||||
default => [],
|
||||
};
|
||||
|
||||
$alliance = null;
|
||||
$airlines = [];
|
||||
|
||||
if ($allianceInternalName) {
|
||||
$alliance = Alliance::where('internal_name', $allianceInternalName)
|
||||
->with('airlines')
|
||||
->firstOrFail();
|
||||
$airlines = $alliance->airlines()->with('country')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
$aircraftFamilies = match($achievement->internal_name){
|
||||
'aircraft.all_boeing_7x7' => Aircraft::BOEING_FAMILIES,
|
||||
'aircraft.all_airbus_a3xx' => Aircraft::AIRBUS_FAMILIES,
|
||||
default => [],
|
||||
};
|
||||
|
||||
$canView = Gate::allows('viewProfileData', $user);
|
||||
|
||||
return Inertia::render('Profile/UserAchievement', [
|
||||
'user' => $user,
|
||||
'achievement' => $achievement,
|
||||
'loggedInUser' => auth()->user(),
|
||||
'userAchievement' => $canView ? $user->achievements()->where('achievement_id', $achievement->id)->first() : null,
|
||||
'followStatus' => auth()->check() ? auth()->user()->followStatus($user) : 'none',
|
||||
'flight_api_url' => UserProfileController::getUserFlightApiURL($user),
|
||||
'regions' => $regions,
|
||||
'alliance' => $alliance,
|
||||
'airlines' => $airlines,
|
||||
'continents' => $continents,
|
||||
'aircraft_families' => $aircraftFamilies,
|
||||
'achievementCount' => $user->unlockedAchievements()->count(),
|
||||
'canView' => $canView,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ class HandleInertiaRequests extends Middleware
|
||||
'logo_api_url' => config('app.logo_api_url'),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'roles' => $request->user()?->getRoleNames() ?? [],
|
||||
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
|
||||
],
|
||||
'achievement_notifications' => fn() => $request->user()
|
||||
? $request->user()
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SanctumOrTrustedOrigin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// Authenticated via Sanctum (cookie or token) — let it through, auth()->user() is set.
|
||||
if ($request->user('sanctum')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Unauthenticated, but coming from our own frontend — let it through too.
|
||||
$origin = $request->headers->get('Origin') ?? $request->headers->get('Referer');
|
||||
$trusted = config('app.trusted_frontend_origins', []);
|
||||
|
||||
foreach ($trusted as $trustedOrigin) {
|
||||
if ($origin && str_starts_with($origin, $trustedOrigin)) {
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
abort(403, 'Forbidden.');
|
||||
}
|
||||
}
|
||||
@@ -39,13 +39,22 @@ class Achievement extends Model
|
||||
'achievement_category_id',
|
||||
'achievement_difficulty_id',
|
||||
'threshold',
|
||||
'has_page',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'has_page' => 'boolean',
|
||||
'progressive' => 'boolean',
|
||||
'threshold' => 'integer',
|
||||
];
|
||||
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'internal_name';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Relationships
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -15,10 +15,12 @@ class Aircraft extends Model
|
||||
'engine_type',
|
||||
'engine_count',
|
||||
'wtc',
|
||||
'preferred'
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'engine_count' => 'integer',
|
||||
'preferred' => 'boolean'
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
@@ -26,11 +28,32 @@ class Aircraft extends Model
|
||||
'display_name_short'
|
||||
];
|
||||
|
||||
const array IATA_ALIAS_MAP = [
|
||||
'7S8' => '73H',
|
||||
'7S9' => '73J'
|
||||
public const array BOEING_FAMILIES = [
|
||||
'707' => ['B701', 'B703', 'B720'],
|
||||
'717' => ['B712', 'B717'],
|
||||
'727' => ['B721', 'B722', 'B727'],
|
||||
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
|
||||
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
|
||||
'757' => ['B752', 'B753', 'B757'],
|
||||
'767' => ['B762', 'B763', 'B764', 'B767'],
|
||||
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
|
||||
'787' => ['B788', 'B789', 'B78X'],
|
||||
];
|
||||
|
||||
public const array AIRBUS_FAMILIES = [
|
||||
'A300' => ['A30B', 'A300', 'A306'],
|
||||
'A310' => ['A310', 'A312', 'A313'],
|
||||
'A318' => ['A318'],
|
||||
'A319' => ['A319', 'A31X'],
|
||||
'A320' => ['A320', 'A20N'],
|
||||
'A321' => ['A321', 'A21N'],
|
||||
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
|
||||
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
|
||||
'A350' => ['A350', 'A358', 'A359', 'A35K'],
|
||||
'A380' => ['A380', 'A388'],
|
||||
];
|
||||
|
||||
|
||||
protected function displayName() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
|
||||
@@ -12,8 +12,8 @@ class Airline extends Model
|
||||
protected $table = 'airlines';
|
||||
|
||||
protected $fillable = [
|
||||
'IATA_code',
|
||||
'ICAO_code',
|
||||
'iata_code',
|
||||
'icao_code',
|
||||
'name',
|
||||
'internal_name',
|
||||
'country_id',
|
||||
@@ -35,7 +35,7 @@ class Airline extends Model
|
||||
protected function displayName() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$codes = array_filter([$this->IATA_code, $this->ICAO_code]);
|
||||
$codes = array_filter([$this->iata_code, $this->icao_code]);
|
||||
$codeString = count($codes) ? ' (' . implode('/', $codes) . ')' : '';
|
||||
return "{$this->name}{$codeString}";
|
||||
}
|
||||
@@ -45,7 +45,13 @@ class Airline extends Model
|
||||
protected function logoUrl() : Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
return config('app.logo_api_url') . "/airline/$this->internal_name/logo/tail";
|
||||
$user = auth()->user();
|
||||
$apiUrl = config('app.logo_api_url');
|
||||
if ($user && !$user->getSetting('ai_tail_logos')) {
|
||||
return $apiUrl .'/airline/blank/logo/tail';
|
||||
}
|
||||
|
||||
return $apiUrl . "/airline/$this->internal_name/logo/tail";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,4 +17,13 @@ class Country extends Model
|
||||
{
|
||||
return $this->hasMany(Region::class);
|
||||
}
|
||||
|
||||
function sortedRegions(): array
|
||||
{
|
||||
return $this
|
||||
->regions()
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ class Followee extends Model
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'followee_id',
|
||||
'verified',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'verified' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
@@ -21,4 +26,14 @@ class Followee extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'followee_id');
|
||||
}
|
||||
|
||||
public function scopeVerified($query)
|
||||
{
|
||||
return $query->where('verified', true);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('verified', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class Guest extends Model
|
||||
{
|
||||
use HasApiTokens;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IgnoredMissingLivery extends Model
|
||||
{
|
||||
protected $table = 'ignored_missing_liveries';
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = ['filename'];
|
||||
|
||||
}
|
||||
@@ -3,58 +3,136 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Http\Controllers\UserFlightController;
|
||||
use App\Settings\SettingsRegistry;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use App\Traits\HasAchievements;
|
||||
use App\Models\Notification;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Fillable(['name', 'email', 'password'])]
|
||||
#[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasAchievements, HasApiTokens;
|
||||
use HasFactory, HasAchievements, HasApiTokens, HasRoles;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
protected $casts = [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
protected $appends = ['resolved_settings'];
|
||||
|
||||
public function achievements(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserAchievement::class);
|
||||
}
|
||||
|
||||
public function getSetting(string $key): mixed
|
||||
{
|
||||
$defaults = SettingsRegistry::defaults();
|
||||
return $this->settings[$key] ?? $defaults[$key] ?? null;
|
||||
}
|
||||
|
||||
public function updateSettings(array $values): void
|
||||
{
|
||||
$current = array_merge(SettingsRegistry::defaults(), $this->settings ?? []);
|
||||
$this->update(['settings' => array_merge($current, $values)]);
|
||||
}
|
||||
|
||||
function updateSetting($settingName, $value) : void{
|
||||
$this->updateSettings([$settingName => $value]);
|
||||
}
|
||||
|
||||
protected function resolvedSettings(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => array_merge(SettingsRegistry::defaults(), $this->settings ?? [])
|
||||
);
|
||||
}
|
||||
|
||||
public function unlockedAchievements(): HasMany
|
||||
{
|
||||
return $this->achievements()
|
||||
->join('achievements', 'achievements.id', '=', 'user_achievements.achievement_id')
|
||||
->where(function ($query) {
|
||||
$query
|
||||
// Non-progressive achievements: always count
|
||||
->where(function ($q) {
|
||||
$q->where('achievements.progressive', false)
|
||||
->orWhereNull('achievements.progressive');
|
||||
})
|
||||
// Progressive achievements: only if progress >= threshold
|
||||
->orWhere(function ($q) {
|
||||
$q->where('achievements.progressive', true)
|
||||
->whereNotNull('achievements.threshold')
|
||||
->whereColumn('user_achievements.progress', '>=', 'achievements.threshold');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?User
|
||||
{
|
||||
return $this->where('name', 'ilike', $value)->firstOrFail();
|
||||
}
|
||||
|
||||
public function FlightController(): UserFlightController
|
||||
{
|
||||
return new UserFlightController($this);
|
||||
}
|
||||
|
||||
public function flights(): HasMany {
|
||||
return $this->hasMany(UserFlight::class);
|
||||
}
|
||||
|
||||
public function departedFlights() : HasMany {
|
||||
return $this->flights()->where('departure_date', '<=', now('UTC'));
|
||||
}
|
||||
|
||||
public function upcomingFlights() : HasMany {
|
||||
return $this->flights()->where('departure_date', '>=', now('UTC'));
|
||||
}
|
||||
|
||||
public function flightsWithRelationshipsLoaded(?string $filter = null): Collection
|
||||
{
|
||||
$key = "user_flights_{$this->id}";
|
||||
|
||||
$json = Cache::remember($key, now()->addDays(30), function () {
|
||||
return $this->flights()
|
||||
->with([
|
||||
'departureAirport.region.country',
|
||||
'departureAirport.region.continent',
|
||||
'arrivalAirport.region.country',
|
||||
'arrivalAirport.region.continent',
|
||||
'airline.country',
|
||||
'airline.alliance',
|
||||
'aircraft',
|
||||
'seatType',
|
||||
'flightReason',
|
||||
'flightClass',
|
||||
'crewType'
|
||||
])
|
||||
->orderBy('departure_date', 'desc')
|
||||
->get()
|
||||
->values()
|
||||
->toJson();
|
||||
});
|
||||
|
||||
$collection = collect(json_decode($json));
|
||||
$today = now('UTC')->toDateString();
|
||||
|
||||
return match ($filter) {
|
||||
'departed' => $collection->filter(fn($f) => $f->departure_date <= $today)->values(),
|
||||
'upcoming' => $collection->filter(fn($f) => $f->departure_date > $today)->values(),
|
||||
default => $collection,
|
||||
};
|
||||
}
|
||||
|
||||
public function ImportedFlights(): HasMany
|
||||
{
|
||||
return $this->hasMany(ImportedFlight::class);
|
||||
@@ -72,7 +150,21 @@ class User extends Authenticatable
|
||||
|
||||
public function isFollowing(User $user): bool
|
||||
{
|
||||
return $this->following()->where('followee_id', $user->id)->exists();
|
||||
return $this->following()
|
||||
->where('followee_id', $user->id)
|
||||
->verified()
|
||||
->exists();
|
||||
}
|
||||
|
||||
public function followStatus(User $user): string
|
||||
{
|
||||
$followee = $this->following()->where('followee_id', $user->id)->first();
|
||||
|
||||
if (!$followee) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return $followee->verified ? 'following' : 'requested';
|
||||
}
|
||||
|
||||
public function notifications(): HasMany
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -26,6 +27,21 @@ class UserAchievement extends Model
|
||||
'progress' => 'integer',
|
||||
];
|
||||
|
||||
protected $appends = [
|
||||
'unlocked',
|
||||
];
|
||||
|
||||
protected function unlocked(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->achievement) return false;
|
||||
if (!$this->achievement->progressive || !$this->achievement->threshold) return true;
|
||||
return ($this->progress ?? 0) >= $this->achievement->threshold;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Relationships
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -33,6 +33,8 @@ class UserAction extends Model
|
||||
'flight_imported' => 'Flight Imported from FR24',
|
||||
'flight_logged' => 'Flight Logged',
|
||||
'flight_deleted' => 'Flight Deleted',
|
||||
'flight_departing' => 'Flight Departed',
|
||||
'flight_arriving' => 'Flight Landed',
|
||||
default => 'Unknown Action'
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Http\Controllers\Api\AirlineApiController;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UserFlight extends Model
|
||||
{
|
||||
@@ -46,6 +48,9 @@ class UserFlight extends Model
|
||||
'duration_display',
|
||||
'distance',
|
||||
'livery_url',
|
||||
'scope',
|
||||
'range',
|
||||
'region_range'
|
||||
];
|
||||
|
||||
public function calculateGreatCircleDistance(): float{
|
||||
@@ -106,6 +111,26 @@ class UserFlight extends Model
|
||||
);
|
||||
}
|
||||
|
||||
protected function scope(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->departureAirport->region->country_id == $this->arrivalAirport->region->country_id ? 'domestic' : 'international'
|
||||
);
|
||||
}
|
||||
protected function range(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->departureAirport->region->continent_id == $this->arrivalAirport->region->continent_id ? 'intracontinental' : 'intercontinental'
|
||||
);
|
||||
}
|
||||
|
||||
protected function regionRange(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn() => $this->departureAirport->region_id == $this->arrivalAirport->region_id ? 'intraregional' : 'interregional'
|
||||
);
|
||||
}
|
||||
|
||||
protected function arrivalDayDifference(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
@@ -224,17 +249,38 @@ class UserFlight extends Model
|
||||
public function liveryUrl(): Attribute{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if($this->airline && $this->aircraft) {
|
||||
$fileName = "{$this->airline->internal_name}_{$this->aircraft->designator}.png";
|
||||
$file = public_path("img/liveries/generated/$fileName");
|
||||
|
||||
if (file_exists($file)) {
|
||||
return "/img/liveries/generated/$fileName";
|
||||
}
|
||||
}
|
||||
$user = auth()?->user();
|
||||
|
||||
$useAi = !$user || $user->getSetting('ai_liveries');
|
||||
|
||||
$apiUrl = config('app.logo_api_url');
|
||||
|
||||
if (!$this->aircraft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if ($this->airline && $useAi){
|
||||
$path = "images/liveries/{$this->airline->internal_name}_{$this->aircraft->designator}.png";
|
||||
if (Storage::disk('local')->exists($path)) {
|
||||
$finalPath = $apiUrl."/airline/{$this->airline->internal_name}/livery/{$this->aircraft->designator}";
|
||||
}
|
||||
}
|
||||
|
||||
if(empty($finalPath)){
|
||||
$path = "images/livery_templates/{$this->aircraft->designator}.png";
|
||||
if (Storage::disk('local')->exists($path)) {
|
||||
$finalPath = $apiUrl."/aircraft/{$this->aircraft->designator}/livery";
|
||||
}
|
||||
}
|
||||
|
||||
if(empty($finalPath)){
|
||||
return null;
|
||||
}
|
||||
|
||||
return $finalPath;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class FlightObserver
|
||||
{
|
||||
protected function clearCache(UserFlight $flight): void
|
||||
{
|
||||
Cache::forget("user_flights_{$flight->user->id}");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Recalculate after a flight is created.
|
||||
*/
|
||||
public function created(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$this->clearCache($flight);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,6 +30,7 @@ class FlightObserver
|
||||
public function updated(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$this->clearCache($flight);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,5 +40,6 @@ class FlightObserver
|
||||
public function deleted(UserFlight $flight): void
|
||||
{
|
||||
$flight->user->calculateAchievements();
|
||||
$this->clearCache($flight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class UserFlightPolicy
|
||||
*/
|
||||
public function update(User $user, UserFlight $userFlight): bool
|
||||
{
|
||||
return $user->id === $userFlight->user_id;
|
||||
return $user->id === $userFlight->user_id || $user->hasRole('admin');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class UserPolicy
|
||||
{
|
||||
|
||||
public function viewProfileData(?User $viewer, User $profileUser): bool
|
||||
{
|
||||
if ($viewer && ($viewer->id === $profileUser->id || $viewer->hasRole('admin'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
$isPrivate = $profileUser->resolved_settings['private_profile'] == 'private';
|
||||
|
||||
if (!$isPrivate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $viewer && $viewer->isFollowing($profileUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, User $model): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, User $model): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, User $model): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, User $model): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, User $model): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ use App\Observers\AirlineObserver;
|
||||
use App\Observers\FlightObserver;
|
||||
use Illuminate\Support\Facades\Vite;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -27,5 +30,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
Vite::prefetch(concurrency: 3);
|
||||
UserFlight::observe(FlightObserver::class);
|
||||
Airline::observe(AirlineObserver::class);
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return $request->user()
|
||||
? Limit::perMinute(60)->by($request->user()->id)
|
||||
: Limit::perMinute(10)->by($request->ip());
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ class AchievementService
|
||||
'arrivalAirport.region',
|
||||
'departureAirport.region.continent',
|
||||
'arrivalAirport.region.continent',
|
||||
])->get();
|
||||
])->where('departure_date', '<=', now('UTC'))->get();
|
||||
|
||||
foreach ($this->checkers as $checkerClass) {
|
||||
$checker = new $checkerClass($this);
|
||||
|
||||
@@ -2,37 +2,13 @@
|
||||
|
||||
namespace App\Services\Achievements\Checkers;
|
||||
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\User;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AircraftChecker extends BaseChecker
|
||||
{
|
||||
private const array BOEING_FAMILIES = [
|
||||
'707' => ['B701', 'B703', 'B720'],
|
||||
'717' => ['B712', 'B717'],
|
||||
'727' => ['B721', 'B722', 'B727'],
|
||||
'737' => ['B731', 'B732', 'B733', 'B734', 'B735', 'B736', 'B737', 'B738', 'B739', 'B37M', 'B38M', 'B39M'],
|
||||
'747' => ['B741', 'B742', 'B743', 'B744', 'B748', 'B74D', 'B74R', 'B74S'],
|
||||
'757' => ['B752', 'B753', 'B757'],
|
||||
'767' => ['B762', 'B763', 'B764', 'B767'],
|
||||
'777' => ['B772', 'B773', 'B77L', 'B77W', 'B778', 'B779'],
|
||||
'787' => ['B788', 'B789', 'B78X'],
|
||||
];
|
||||
|
||||
private const array AIRBUS_FAMILIES = [
|
||||
'A300' => ['A30B', 'A300', 'A306'],
|
||||
'A310' => ['A310', 'A312', 'A313'],
|
||||
'A318' => ['A318'],
|
||||
'A319' => ['A319', 'A31X'],
|
||||
'A320' => ['A320', 'A20N'],
|
||||
'A321' => ['A321', 'A21N'],
|
||||
'A330' => ['A330', 'A332', 'A333', 'A338', 'A339'],
|
||||
'A340' => ['A340', 'A342', 'A343', 'A345', 'A346'],
|
||||
'A350' => ['A350', 'A358', 'A359', 'A35K'],
|
||||
'A380' => ['A380', 'A388'],
|
||||
];
|
||||
|
||||
private const array DOUBLE_DECKER_DESIGNATORS = [
|
||||
// A380
|
||||
'A380', 'A388',
|
||||
@@ -121,7 +97,7 @@ class AircraftChecker extends BaseChecker
|
||||
|
||||
// --- Boeing 7x7 families ---
|
||||
|
||||
$flownBoeingFamilies = collect(self::BOEING_FAMILIES)
|
||||
$flownBoeingFamilies = collect(Aircraft::BOEING_FAMILIES)
|
||||
->filter(fn($designators) =>
|
||||
$flightsWithAircraft->contains(
|
||||
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||
@@ -133,7 +109,7 @@ class AircraftChecker extends BaseChecker
|
||||
|
||||
// --- Airbus A3xx families ---
|
||||
|
||||
$flownAirbusFamilie = collect(self::AIRBUS_FAMILIES)
|
||||
$flownAirbusFamilie = collect(Aircraft::AIRBUS_FAMILIES)
|
||||
->filter(fn($designators) =>
|
||||
$flightsWithAircraft->contains(
|
||||
fn(UserFlight $f) => in_array($f->aircraft->designator, $designators)
|
||||
|
||||
@@ -76,6 +76,13 @@ class CountriesAndContinentsChecker extends BaseChecker
|
||||
$dep = $flight->departureAirport->region->continent->internal_name;
|
||||
$arr = $flight->arrivalAirport->region->continent->internal_name;
|
||||
|
||||
if (!in_array($dep, self::INHABITED_CONTINENTS) || !in_array($arr, self::INHABITED_CONTINENTS)) continue;
|
||||
if ($dep === $arr) {
|
||||
$depCountry = $flight->departureAirport->region->country_id;
|
||||
$arrCountry = $flight->arrivalAirport->region->country_id;
|
||||
if ($depCountry === $arrCountry) continue;
|
||||
}
|
||||
|
||||
// Directed route key e.g. "europe→asia"
|
||||
$directedRoutes->push("{$dep}→{$arr}");
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ class FunChallengesChecker extends BaseChecker
|
||||
$flights = $this->flights();
|
||||
|
||||
$airlineLetters = $flights
|
||||
->filter(fn(UserFlight $f) => $f->airline?->IATA_code !== null)
|
||||
->map(fn(UserFlight $f) => strtoupper($f->airline->IATA_code[0]))
|
||||
->filter(fn(UserFlight $f) => $f->airline?->iata_code !== null)
|
||||
->map(fn(UserFlight $f) => strtoupper($f->airline->iata_code[0]))
|
||||
->filter(fn($letter) => ctype_alpha($letter))
|
||||
->unique()
|
||||
->count();
|
||||
|
||||
@@ -70,6 +70,12 @@ class GeneralFlyingChecker extends BaseChecker
|
||||
|
||||
// --- Progressive achievements ---
|
||||
|
||||
$totalDistance = $flights->sum('distance');
|
||||
|
||||
$this->awardProgress((int) $totalDistance, 'general_flying.circumference_of_the_earth');
|
||||
$this->awardProgress((int) $totalDistance, 'general_flying.to_the_moon');
|
||||
$this->awardProgress((int) $totalDistance, 'general_flying.gigametre');
|
||||
|
||||
$this->awardProgress($count,'general_flying.10_flights');
|
||||
$this->awardProgress($count,'general_flying.50_flights');
|
||||
$this->awardProgress($count,'general_flying.100_flights');
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTOs\MissingLivery;
|
||||
use App\Models\IgnoredMissingLivery;
|
||||
use App\Models\UserFlight;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AdminService
|
||||
{
|
||||
/** @return Collection<int, MissingLivery> */
|
||||
function getMissingLiveries(): Collection
|
||||
{
|
||||
|
||||
/* $existingFiles = collect(glob(public_path('img/liveries/generated/*')))
|
||||
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||
->toArray();*/
|
||||
|
||||
|
||||
$existingFiles = collect(glob(Storage::disk('local')->path('images/liveries').'/*.png'))
|
||||
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
|
||||
->toArray();
|
||||
|
||||
$combos = UserFlight::with(['aircraft', 'airline'])
|
||||
->select('airline_id', 'aircraft_id')
|
||||
->whereNotNull('airline_id')
|
||||
->whereNotNull('aircraft_id')
|
||||
->distinct()
|
||||
->get()
|
||||
->filter(fn ($flight) => $flight->aircraft && $flight->airline)
|
||||
->map(fn ($flight) => [
|
||||
'airline_name' => $flight->airline->name,
|
||||
'aircraft_display_name' => $flight->aircraft->display_name,
|
||||
'filename' => $flight->airline->internal_name . '_' . $flight->aircraft->designator,
|
||||
'clipboard_text' => $flight->airline->name . ' ' . $flight->aircraft->display_name_short,
|
||||
])
|
||||
->filter(fn ($combo) => !in_array($combo['filename'], $existingFiles));
|
||||
|
||||
$ignoredFiles = IgnoredMissingLivery::whereIn('filename', $combos->pluck('filename'))->pluck('filename')->toArray();
|
||||
|
||||
return $combos
|
||||
->filter(fn ($combo) => !in_array($combo['filename'], $ignoredFiles))
|
||||
->sortBy('airline_name')
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\DTOs\FlightStatData;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FlightStatsService
|
||||
{
|
||||
|
||||
public function fetchOtherDays(string $airlineCode, string $flightNumber): array
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/other-days/%s/%s',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
|
||||
if (!is_array($data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function fetchFlightData(string $airlineCode, string $flightNumber, ?CarbonImmutable $date = null): ?FlightStatData
|
||||
{
|
||||
$specificDate = $date !== null;
|
||||
$date ??= now()->utc()->toImmutable();
|
||||
|
||||
$data = $this->fetchForDate($airlineCode, $flightNumber, $date);
|
||||
|
||||
if ($data || $specificDate) return $data;
|
||||
|
||||
$otherDays = $this->fetchOtherDays($airlineCode, $flightNumber);
|
||||
|
||||
$pastDays = collect($otherDays)
|
||||
->filter(fn($day) => !empty($day['flights']))
|
||||
->sortByDesc(fn($day) => $day['year'] . $day['date1']);
|
||||
|
||||
foreach ($pastDays as $day) {
|
||||
$pastDate = CarbonImmutable::createFromFormat('Y-d-M', $day['year'] . '-' . $day['date1']);
|
||||
$result = $this->fetchForDate($airlineCode, $flightNumber, $pastDate);
|
||||
if ($result) return $result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function fetchForDate(string $airlineCode, string $flightNumber, CarbonImmutable $date): ?FlightStatData
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://www.flightstats.com/v2/api-next/flight-tracker/%s/%s/%d/%d/%d',
|
||||
$airlineCode,
|
||||
$flightNumber,
|
||||
$date->year,
|
||||
$date->month,
|
||||
$date->day,
|
||||
);
|
||||
|
||||
$response = Http::withOptions([
|
||||
'verify' => config('app.verify_ssl'),
|
||||
])->get($url);
|
||||
|
||||
if (!$response->successful()) {
|
||||
Log::warning("FlightStats request failed for {$airlineCode}{$flightNumber}: HTTP {$response->status()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$flightData = $response->json('data');
|
||||
|
||||
if (empty($flightData)) return null;
|
||||
|
||||
return FlightStatData::fromApiResponse($flightData);
|
||||
}
|
||||
|
||||
public function guessAircraftFromIata(string $iataCode): ?Aircraft
|
||||
{
|
||||
$equipment = IataEquipmentCode::where('iata_code', $iataCode)->first();
|
||||
|
||||
if (!$equipment) {
|
||||
Log::info("Unknown IATA equipment code: {$iataCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$aircraft = Aircraft::where('designator', $equipment->icao_code)
|
||||
->orderByDesc('preferred')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (!$aircraft) {
|
||||
Log::info("No aircraft found for ICAO: {$equipment->icao_code} (IATA: {$iataCode})");
|
||||
}
|
||||
|
||||
return $aircraft;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settings;
|
||||
|
||||
class SettingsRegistry
|
||||
{
|
||||
|
||||
public static function categories(): array
|
||||
{
|
||||
return [
|
||||
'Units of Measurement' => 'Select either metric or incorrect units.',
|
||||
'FlightsGoneBy Settings' => 'Settings for Site Behaviour',
|
||||
'AI Generated Content' => 'Airline tail logos and liveries are AI generated with human cleanup. If you would rather not see any AI, then our blank aircraft templates are human created.',
|
||||
'Account & Privacy' => 'Everything to do with your account.',
|
||||
];
|
||||
}
|
||||
|
||||
public static function schema(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'key' => 'distance_unit',
|
||||
'type' => 'select',
|
||||
'label' => 'Distance Units',
|
||||
'category' => 'Units of Measurement',
|
||||
'default' => 'km',
|
||||
'options' => [
|
||||
['value' => 'km', 'label' => 'Kilometres (km)'],
|
||||
['value' => 'mi', 'label' => 'Miles (mi)'],
|
||||
['value' => 'nm', 'label' => 'Nautical miles (nm)'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'category' => 'Account & Privacy',
|
||||
'key' => 'private_profile',
|
||||
'type' => 'select',
|
||||
'label' => 'Account Privacy',
|
||||
'default' => 'public',
|
||||
'options' => [
|
||||
['value' => 'public', 'label' => 'Public Profile Viewable By Everyone'],
|
||||
['value' => 'private', 'label' => 'Private Profile Viewable Only By You and Your Approved Followers'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'key' => 'default_login_page',
|
||||
'type' => 'select',
|
||||
'label' => 'Default Page',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'default' => 'feed_first',
|
||||
'options' => [
|
||||
['value' => 'feed_first', 'label' => 'My Feed if Following People, My Profile if Not'],
|
||||
['value' => 'profile', 'label' => 'My Profile'],
|
||||
['value' => 'feed', 'label' => 'My Feed'],
|
||||
['value' => 'dashboard', 'label' => 'My Dashboard'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'default_profile_view',
|
||||
'type' => 'select',
|
||||
'label' => 'Default View When Loading a Profile',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'default' => 'departure-board',
|
||||
'options' => [
|
||||
['value' => 'departure-board', 'label' => 'Departure Board'],
|
||||
['value' => 'map', 'label' => 'Map'],
|
||||
['value' => 'boarding-passes', 'label' => 'Boarding Passes'],
|
||||
['value' => 'achievements', 'label' => 'Achievements'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'key' => 'show_map_legend',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Expand Map Legend By Default',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'hide_impossible_achievements',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Hide Impossible Achievements By Default',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'category' => 'AI Generated Content',
|
||||
'key' => 'ai_liveries',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Show AI Generated Livery Images',
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'category' => 'AI Generated Content',
|
||||
'key' => 'ai_tail_logos',
|
||||
'type' => 'checkbox',
|
||||
'label' => 'Show AI Generated Tail Logos',
|
||||
'default' => true,
|
||||
],
|
||||
[
|
||||
'key' => 'departure_board_columns',
|
||||
'category' => 'FlightsGoneBy Settings',
|
||||
'type' => 'multiselect',
|
||||
'label' => 'Which columns to show on the Departure Board',
|
||||
'default' => ['airline', 'flight_number', 'from', 'to', 'departure_date', 'departure_time', 'arrival_time', 'duration', 'distance', 'aircraft', 'registration', 'class_seat_combined'],
|
||||
'options' => [
|
||||
['value' => 'airline', 'label' => 'Airline'],
|
||||
['value' => 'flight_number', 'label' => 'Flight Number'],
|
||||
['value' => 'from', 'label' => 'From'],
|
||||
['value' => 'to', 'label' => 'To'],
|
||||
['value' => 'departure_date', 'label' => 'Departure Date'],
|
||||
['value' => 'departure_time', 'label' => 'Departure Time'],
|
||||
['value' => 'arrival_time', 'label' => 'Arrival Time'],
|
||||
['value' => 'duration', 'label' => 'Duration'],
|
||||
['value' => 'distance', 'label' => 'Distance'],
|
||||
['value' => 'aircraft', 'label' => 'Aircraft'],
|
||||
['value' => 'registration', 'label' => 'Aircraft Registration'],
|
||||
['value' => 'class_seat_combined', 'label' => 'Class/Seat Type/Seat Number Combined'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaults(): array
|
||||
{
|
||||
return collect(static::schema())
|
||||
->pluck('default', 'key')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public static function validationRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
foreach (static::schema() as $field) {
|
||||
$key = "settings.{$field['key']}";
|
||||
$rules[$key] = match ($field['type']) {
|
||||
'select' => ['required', 'string', 'in:' . implode(',', array_column($field['options'], 'value'))],
|
||||
'checkbox' => ['boolean'],
|
||||
'text' => ['nullable', 'string', 'max:255'],
|
||||
'multiselect' => ['nullable', 'array'],
|
||||
"settings.{$field['key']}.*" => ['string'],
|
||||
default => ['nullable'],
|
||||
};
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ trait HasAchievements
|
||||
{
|
||||
public function calculateAchievements(): void
|
||||
{
|
||||
/** @var User $this */
|
||||
app(AchievementService::class)->calculate($this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Spatie\Permission\Middleware\PermissionMiddleware;
|
||||
use Spatie\Permission\Middleware\RoleMiddleware;
|
||||
use Spatie\Permission\Middleware\RoleOrPermissionMiddleware;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -10,15 +21,98 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
apiPrefix: '',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
$middleware->alias([
|
||||
'role' => RoleMiddleware::class,
|
||||
'permission' => PermissionMiddleware::class,
|
||||
'role_or_permission' => RoleOrPermissionMiddleware::class,
|
||||
]);
|
||||
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
|
||||
if ($request->getHost() !== config('app.api_domain')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($e->getPrevious() instanceof ModelNotFoundException) {
|
||||
return response()->json(['message' => 'Resource not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
});
|
||||
|
||||
$exceptions->respond(function ($response, Throwable $e, Request $request) {
|
||||
$status = $response->getStatusCode();
|
||||
|
||||
// API domain: never touch the response, let Laravel's own JSON rendering stand.
|
||||
if ($request->getHost() === config('app.api_domain')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$errors = [
|
||||
403 => [
|
||||
'title' => "The Cockpit is Off Limits",
|
||||
'message' => 'You don\'t have permission to access this page.',
|
||||
],
|
||||
404 => [
|
||||
'title' => 'You Flight Has Been Cancelled',
|
||||
'message' => 'The page you are looking for doesn\'t exist or has been moved.',
|
||||
],
|
||||
419 => [
|
||||
'title' => 'Page Expired',
|
||||
'message' => 'Your session has expired. Please refresh the page and try again.',
|
||||
],
|
||||
429 => [
|
||||
'title' => 'Too Many Requests',
|
||||
'message' => 'You\'re making too many requests. Please slow down and try again.',
|
||||
],
|
||||
500 => [
|
||||
'title' => 'This Plane Has Made An Emergency Landing',
|
||||
'message' => 'Something went wrong on our end. Please try again later.',
|
||||
],
|
||||
503 => [
|
||||
'title' => 'Service Unavailable',
|
||||
'message' => 'We\'re down for maintenance. Please check back soon.',
|
||||
],
|
||||
];
|
||||
|
||||
$isLocal = app()->environment(['local', 'testing']);
|
||||
$friendlyErrorsOnLocal = [404, 403];
|
||||
|
||||
$shouldHandle = isset($errors[$status]) && (
|
||||
!$isLocal || in_array($status, $friendlyErrorsOnLocal)
|
||||
);
|
||||
|
||||
if (!$shouldHandle) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return Inertia::render('Error', [
|
||||
'statusCode' => $status,
|
||||
'statusTitle' => $errors[$status]['title'],
|
||||
'statusMessage' => $errors[$status]['message'],
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'roles' => $request->user()?->getRoleNames() ?? [],
|
||||
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
|
||||
],
|
||||
])
|
||||
->toResponse($request)
|
||||
->setStatusCode($status);
|
||||
})
|
||||
->shouldRenderJsonWhen(function ($request, Throwable $e) {
|
||||
if ($request->getHost() === config('app.api_domain')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $request->expectsJson();
|
||||
});
|
||||
})
|
||||
->create();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"spatie/laravel-permission": "^8.0",
|
||||
"tightenco/ziggy": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "0e560320885031dd36bb08bb44fe05d4",
|
||||
"content-hash": "2fab3a0703fff56cedb9d4c9b650e2fc",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -3433,6 +3433,154 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-package-tools",
|
||||
"version": "1.93.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||
"reference": "d5552849801f2642aea710557463234b59ef65eb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
|
||||
"reference": "d5552849801f2642aea710557463234b59ef65eb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5",
|
||||
"orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
|
||||
"pestphp/pest": "^2.1|^3.1|^4.0",
|
||||
"phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
|
||||
"phpunit/phpunit": "^10.5|^11.5|^12.5",
|
||||
"spatie/pest-plugin-test-time": "^2.2|^3.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Spatie\\LaravelPackageTools\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Tools for creating Laravel packages",
|
||||
"homepage": "https://github.com/spatie/laravel-package-tools",
|
||||
"keywords": [
|
||||
"laravel-package-tools",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-19T14:06:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/laravel-permission",
|
||||
"version": "8.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spatie/laravel-permission.git",
|
||||
"reference": "70a6ab04108616b438e0839598f473b513281644"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/70a6ab04108616b438e0839598f473b513281644",
|
||||
"reference": "70a6ab04108616b438e0839598f473b513281644",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/auth": "^12.0|^13.0",
|
||||
"illuminate/container": "^12.0|^13.0",
|
||||
"illuminate/contracts": "^12.0|^13.0",
|
||||
"illuminate/database": "^12.0|^13.0",
|
||||
"php": "^8.3",
|
||||
"spatie/laravel-package-tools": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^3.9",
|
||||
"laravel/passport": "^13.0",
|
||||
"laravel/pint": "^1.0",
|
||||
"orchestra/testbench": "^10.0|^11.0",
|
||||
"pestphp/pest": "^3.0|^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.0|^4.1",
|
||||
"phpstan/phpstan": "^2.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Spatie\\Permission\\PermissionServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "8.x-dev",
|
||||
"dev-master": "8.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Spatie\\Permission\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Freek Van der Herten",
|
||||
"email": "freek@spatie.be",
|
||||
"homepage": "https://spatie.be",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Permission handling for Laravel 12 and up",
|
||||
"homepage": "https://github.com/spatie/laravel-permission",
|
||||
"keywords": [
|
||||
"acl",
|
||||
"laravel",
|
||||
"permission",
|
||||
"permissions",
|
||||
"rbac",
|
||||
"roles",
|
||||
"security",
|
||||
"spatie"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/spatie/laravel-permission/issues",
|
||||
"source": "https://github.com/spatie/laravel-permission/tree/8.0.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/spatie",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-05-30T19:30:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v8.0.0",
|
||||
|
||||
@@ -58,6 +58,7 @@ return [
|
||||
'api_domain' => env('API_DOMAIN', 'api.flightsgoneby.com'),
|
||||
'logo_api_url' => env('LOGO_API_URL', 'https://api.flightsgoneby.com'),
|
||||
'timezone_api_key' => env('TIMEZONE_API_KEY', '1234567890'),
|
||||
'trusted_frontend_origins' => array_filter(explode(',', env('TRUSTED_FRONTEND_ORIGINS', ''))),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure your settings for cross-origin resource sharing
|
||||
| or "CORS". This determines what cross-origin operations may execute
|
||||
| in web browsers. You are free to adjust these settings as needed.
|
||||
|
|
||||
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||
|
|
||||
*/
|
||||
|
||||
'paths' => ['*'],
|
||||
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
'https://flightsgoneby.com',
|
||||
'https://www.flightsgoneby.com',
|
||||
'http://flightsgoneby.test:8000',
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
'allowed_headers' => ['*'],
|
||||
|
||||
'exposed_headers' => [],
|
||||
|
||||
'max_age' => 0,
|
||||
|
||||
'supports_credentials' => true,
|
||||
|
||||
];
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'root' => env('LOCAL_DISK_ROOT', storage_path('app/private')),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Permission\DefaultTeamResolver;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return [
|
||||
|
||||
'models' => [
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||
* is often just the "Permission" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Permission model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Permission` contract.
|
||||
*/
|
||||
|
||||
'permission' => Permission::class,
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||
* is often just the "Role" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Role model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Role` contract.
|
||||
*/
|
||||
|
||||
'role' => Role::class,
|
||||
|
||||
/*
|
||||
* When using the "Teams" feature from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your teams. Of course, it
|
||||
* is often just the "Team" model but you may use whatever you like.
|
||||
*/
|
||||
'team' => null,
|
||||
|
||||
/*
|
||||
* When using the "HasModels" trait and passing raw IDs to syncModels,
|
||||
* attachModels, or detachModels, this model class will be used to
|
||||
* resolve those IDs. If null, defaults to the guard's model.
|
||||
*/
|
||||
'default_model' => null,
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'roles' => 'roles',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your permissions. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'permissions' => 'permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_permissions' => 'model_has_permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models roles. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_roles' => 'model_has_roles',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'role_has_permissions' => 'role_has_permissions',
|
||||
],
|
||||
|
||||
'column_names' => [
|
||||
/*
|
||||
* Change this if you want to name the related pivots other than defaults
|
||||
*/
|
||||
'role_pivot_key' => null, // default 'role_id',
|
||||
'permission_pivot_key' => null, // default 'permission_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to name the related model primary key other than
|
||||
* `model_id`.
|
||||
*
|
||||
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||
* that case, name this `model_uuid`.
|
||||
*/
|
||||
|
||||
'model_morph_key' => 'model_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to use the teams feature and your related model's
|
||||
* foreign key is other than `team_id`.
|
||||
*/
|
||||
|
||||
'team_foreign_key' => 'team_id',
|
||||
],
|
||||
|
||||
/*
|
||||
* When set to true, the method for checking permissions will be registered on the gate.
|
||||
* Set this to false if you want to implement custom logic for checking permissions.
|
||||
*/
|
||||
|
||||
'register_permission_check_method' => true,
|
||||
|
||||
/*
|
||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||
*/
|
||||
'register_octane_reset_listener' => false,
|
||||
|
||||
/*
|
||||
* Events will fire when a role or permission is assigned/unassigned:
|
||||
* \Spatie\Permission\Events\RoleAttachedEvent
|
||||
* \Spatie\Permission\Events\RoleDetachedEvent
|
||||
* \Spatie\Permission\Events\PermissionAttachedEvent
|
||||
* \Spatie\Permission\Events\PermissionDetachedEvent
|
||||
*
|
||||
* To enable, set to true, and then create listeners to watch these events.
|
||||
*/
|
||||
'events_enabled' => false,
|
||||
|
||||
/*
|
||||
* Teams Feature.
|
||||
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||
* If you want the migrations to register the 'team_foreign_key', you must
|
||||
* set this to true before doing the migration.
|
||||
* If you already did the migration then you must make a new migration to also
|
||||
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||
* (view the latest version of this package's migration file)
|
||||
*/
|
||||
|
||||
'teams' => false,
|
||||
|
||||
/*
|
||||
* The class to use to resolve the permissions team id
|
||||
*/
|
||||
'team_resolver' => DefaultTeamResolver::class,
|
||||
|
||||
/*
|
||||
* Passport Client Credentials Grant
|
||||
* When set to true the package will use Passports Client to check permissions
|
||||
*/
|
||||
|
||||
'use_passport_client_credentials' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required permission names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_permission_in_exception' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required role names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_role_in_exception' => false,
|
||||
|
||||
/*
|
||||
* By default wildcard permission lookups are disabled.
|
||||
* See documentation to understand supported syntax.
|
||||
*/
|
||||
|
||||
'enable_wildcard_permission' => false,
|
||||
|
||||
/*
|
||||
* The class to use for interpreting wildcard permissions.
|
||||
* If you need to modify delimiters, override the class and specify its name here.
|
||||
*/
|
||||
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||
|
||||
/* Cache-specific settings */
|
||||
|
||||
'cache' => [
|
||||
|
||||
/*
|
||||
* By default all permissions are cached for 24 hours to speed up performance.
|
||||
* When permissions or roles are updated the cache is flushed automatically.
|
||||
*/
|
||||
|
||||
'expiration_time' => DateInterval::createFromDateString('24 hours'),
|
||||
|
||||
/*
|
||||
* The cache key used to store all permissions.
|
||||
*/
|
||||
|
||||
'key' => 'spatie.permission.cache',
|
||||
|
||||
/*
|
||||
* You may optionally indicate a specific cache driver to use for permission and
|
||||
* role caching using any of the `store` drivers listed in the cache.php config
|
||||
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||
*/
|
||||
|
||||
'store' => 'default',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
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('achievements', function (Blueprint $table) {
|
||||
$table->boolean('has_page')->default(false);
|
||||
});
|
||||
|
||||
$achievements = [
|
||||
'airlines_alliances.all_skyteam',
|
||||
'airlines_alliances.all_oneworld',
|
||||
'airlines_alliances.all_star_alliance',
|
||||
'airlines_alliances.all_vanilla_alliance',
|
||||
'fun_challenges.airline_alphabet',
|
||||
'fun_challenges.airport_alphabet',
|
||||
'fun_challenges.brazilian_states',
|
||||
'fun_challenges.us_states',
|
||||
'fun_challenges.australian_states',
|
||||
'fun_challenges.chinese_provinces',
|
||||
'fun_challenges.canadian_provinces',
|
||||
'countries_continents.all_continent_pairs_one_way',
|
||||
'countries_continents.all_continent_pairs_both_ways',
|
||||
];
|
||||
|
||||
foreach($achievements as $achievement) {
|
||||
Achievement::where('internal_name', $achievement)->update(['has_page' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\AchievementCategory;
|
||||
use App\Models\AchievementDifficulty;
|
||||
use App\Models\Airline;
|
||||
use App\Models\User;
|
||||
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
|
||||
{
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Circumnavigator',
|
||||
'internal_name' => 'general_flying.circumference_of_the_earth',
|
||||
'short_description' => 'Fly the same distance as the circumference of the Earth at the equator!',
|
||||
'icon' => 'standard_achievement.png',
|
||||
'progressive' => true,
|
||||
'long_description' => '',
|
||||
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
|
||||
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'moderate')->first()->id,
|
||||
'threshold' => 40075,
|
||||
'has_page' => false,
|
||||
]);
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Fly Me to The Moon',
|
||||
'internal_name' => 'general_flying.to_the_moon',
|
||||
'short_description' => 'Fly the same distance as the Earth to the Moon!',
|
||||
'icon' => 'standard_achievement.png',
|
||||
'long_description' => '',
|
||||
'progressive' => true,
|
||||
'threshold' => 384400,
|
||||
'achievement_category_id' => AchievementCategory::where('internal_name', 'general_flying')->first()->id,
|
||||
'achievement_difficulty_id' => AchievementDifficulty::where('internal_name', 'hard')->first()->id,
|
||||
'has_page' => false,
|
||||
]);
|
||||
|
||||
Achievement::whereInternalName('aircraft.all_boeing_7x7')->update(['has_page' => true]);
|
||||
Achievement::whereInternalName('aircraft.all_airbus_a3xx')->update(['has_page' => true]);
|
||||
Airline::whereInternalName('south-africa-airways')->update(['name' => 'South African Airways']);
|
||||
|
||||
Schema::table('achievements', function (Blueprint $table) {
|
||||
$table->unsignedInteger('sort_order')->nullable()->after('id');
|
||||
});
|
||||
|
||||
// Seed sort_order from current id order, scoped per category
|
||||
$achievements = DB::table('achievements')
|
||||
->orderBy('achievement_category_id')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$position = 1;
|
||||
$currentCategory = null;
|
||||
|
||||
foreach ($achievements as $achievement) {
|
||||
if ($achievement->achievement_category_id !== $currentCategory) {
|
||||
$position = 1;
|
||||
$currentCategory = $achievement->achievement_category_id;
|
||||
}
|
||||
DB::table('achievements')
|
||||
->where('id', $achievement->id)
|
||||
->update(['sort_order' => $position++]);
|
||||
}
|
||||
|
||||
// Move "Four on the Floor" (id 30) after "Triple Threat" (id 34)
|
||||
// within the aircraft category — swap their sort_order values
|
||||
$triEngine = DB::table('achievements')->where('internal_name', 'aircraft.tri_engine')->first();
|
||||
$quadEngine = DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')->first();
|
||||
|
||||
DB::table('achievements')->where('internal_name', 'aircraft.quad_engine')
|
||||
->update(['sort_order' => $triEngine->sort_order + 1]);
|
||||
|
||||
// Shift everything between them up by 1 to make room
|
||||
DB::table('achievements')
|
||||
->where('achievement_category_id', $quadEngine->achievement_category_id)
|
||||
->where('sort_order', '>=', $triEngine->sort_order + 1)
|
||||
->where('internal_name', '!=', 'aircraft.quad_engine')
|
||||
->increment('sort_order');
|
||||
|
||||
$users = User::all();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Achievement;
|
||||
use App\Models\AchievementCategory;
|
||||
use App\Models\AchievementDifficulty;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\IataEquipmentCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
|
||||
function setDesignatorDefault($designator, $model_name, $manufacturer_code=null): self
|
||||
{
|
||||
$manufacturerSearchTerm = $manufacturer_code ? ['manufacturer_code' => $manufacturer_code] : [];
|
||||
|
||||
$count = Aircraft::where([
|
||||
'designator' => $designator,
|
||||
'model_full_name' => $model_name,
|
||||
...$manufacturerSearchTerm,
|
||||
])->update(['preferred' => true]);
|
||||
|
||||
echo $designator . ' ' . $model_name . ' ' . $manufacturer_code . ' ' . $count . PHP_EOL;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
function setDesignatorDefaults(): void
|
||||
{
|
||||
$this
|
||||
->setDesignatorDefault('A19N', 'A-319neo')
|
||||
->setDesignatorDefault('A306', 'A-300B4-600')
|
||||
->setDesignatorDefault('A30B', 'A-300B4-200')
|
||||
->setDesignatorDefault('A310', 'A-310')
|
||||
->setDesignatorDefault('A318', 'A-318')
|
||||
->setDesignatorDefault('A319', 'A-319')
|
||||
->setDesignatorDefault('A320', 'A-320')
|
||||
->setDesignatorDefault('A332', 'A-330-200')
|
||||
->setDesignatorDefault('A338', 'A-330-800')
|
||||
->setDesignatorDefault('A339', 'A-330-900')
|
||||
->setDesignatorDefault('A342', 'A-340-200')
|
||||
->setDesignatorDefault('A343', 'A-340-300')
|
||||
->setDesignatorDefault('A345', 'A-340-500')
|
||||
->setDesignatorDefault('A346', 'A-340-600')
|
||||
->setDesignatorDefault('A359', 'A-350-900 XWB')
|
||||
->setDesignatorDefault('A35K', 'A-350-1000 XWB')
|
||||
->setDesignatorDefault('A388', 'A-380-800')
|
||||
->setDesignatorDefault('AN24', 'An-24')
|
||||
->setDesignatorDefault('AN26', 'An-26')
|
||||
->setDesignatorDefault('AT43', 'ATR-42-300')
|
||||
->setDesignatorDefault('AT44', 'ATR-42-400')
|
||||
->setDesignatorDefault('AT72', 'ATR-72-201')
|
||||
->setDesignatorDefault('AT73', 'ATR-72-211')
|
||||
->setDesignatorDefault('AT75', 'ATR-72-500')
|
||||
->setDesignatorDefault('AT76', 'ATR-72-600')
|
||||
->setDesignatorDefault('B37M', '737 MAX 7')
|
||||
->setDesignatorDefault('B38M', '737 MAX 8')
|
||||
->setDesignatorDefault('B39M', '737 MAX 9')
|
||||
->setDesignatorDefault('B3XM', '737 MAX 10')
|
||||
->setDesignatorDefault('B461', 'BAe-146-100')
|
||||
->setDesignatorDefault('B462', 'BAe-146-200')
|
||||
->setDesignatorDefault('B703', '707-300')
|
||||
->setDesignatorDefault('B712', '717-200')
|
||||
->setDesignatorDefault('B721', '727-100')
|
||||
->setDesignatorDefault('B732', '737-200')
|
||||
->setDesignatorDefault('B737', '737-700')
|
||||
->setDesignatorDefault('B738', '737-800')
|
||||
->setDesignatorDefault('B739', '737-900')
|
||||
->setDesignatorDefault('B742', '747-200')
|
||||
->setDesignatorDefault('B748', '747-8')
|
||||
->setDesignatorDefault('B752', '757-200')
|
||||
->setDesignatorDefault('B772', '777-200')
|
||||
->setDesignatorDefault('B778', '777-8')
|
||||
->setDesignatorDefault('B77L', '777-200LR')
|
||||
->setDesignatorDefault('B77W', '777-300ER')
|
||||
->setDesignatorDefault('B788', '787-8 Dreamliner')
|
||||
->setDesignatorDefault('B789', '787-9 Dreamliner')
|
||||
->setDesignatorDefault('BCS1', 'A-220-100')
|
||||
->setDesignatorDefault('BCS3', 'A-220-300')
|
||||
->setDesignatorDefault('CRJ1', 'CL-600 Regional Jet CRJ-100')
|
||||
->setDesignatorDefault('CRJ2', 'CL-600 Regional Jet CRJ-200')
|
||||
->setDesignatorDefault('CRJ7', 'CL-600 Regional Jet CRJ-700')
|
||||
->setDesignatorDefault('CRJ9', 'CL-600 Regional Jet CRJ-900')
|
||||
->setDesignatorDefault('DC10', 'DC-10')
|
||||
->setDesignatorDefault('DC3', 'DC-3')
|
||||
->setDesignatorDefault('DH8B', 'DHC-8-200 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8A', 'DHC-8-100 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8C', 'DHC-8-300 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DH8D', 'DHC-8-400 Dash 8' , 'DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('DHC6', 'DHC-6 Twin Otter','DE HAVILLAND CANADA')
|
||||
->setDesignatorDefault('E120', 'EMB-120 Brasilia', 'EMBRAER')
|
||||
->setDesignatorDefault('E135', 'ERJ-135')
|
||||
->setDesignatorDefault('E145', 'ERJ-145ER')
|
||||
->setDesignatorDefault('E170', '170')
|
||||
->setDesignatorDefault('E190', '190')
|
||||
->setDesignatorDefault('E195', '195')
|
||||
->setDesignatorDefault('E275', 'E175-E2')
|
||||
->setDesignatorDefault('E290', 'E190-E2')
|
||||
->setDesignatorDefault('E295', 'E195-E2')
|
||||
->setDesignatorDefault('F27', 'F-27 Friendship', 'FOKKER')
|
||||
->setDesignatorDefault('JS32', 'BAe-3200 Jetstream Super 31', 'BRITISH AEROSPACE')
|
||||
->setDesignatorDefault('JS41', 'BAe-4100 Jetstream 41', 'BRITISH AEROSPACE')
|
||||
->setDesignatorDefault('MD81', 'MD-81', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD82', 'MD-82', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD83', 'MD-83', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD87', 'MD-87', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD88', 'MD-88', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('MD90', 'MD-90', 'MCDONNELL DOUGLAS')
|
||||
->setDesignatorDefault('AJ27', 'C-909')
|
||||
;
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('aircraft', function (Blueprint $table) {
|
||||
$table->boolean('preferred')->default(false)->after('designator');
|
||||
});
|
||||
|
||||
$this->setDesignatorDefaults();
|
||||
|
||||
IataEquipmentCode::create([
|
||||
'iata_code' => '388',
|
||||
'icao_code' => 'A388',
|
||||
'description' => 'Airbus A380'
|
||||
]);
|
||||
|
||||
Achievement::create([
|
||||
'name' => 'Gone a Gigametre',
|
||||
'internal_name' => 'general_flying.gigametre',
|
||||
'short_description' => 'Fly 1 million kilometres.',
|
||||
'long_description' => '',
|
||||
'achievement_difficulty_id' => AchievementDifficulty::whereInternalName('near_impossible')->first()->id,
|
||||
'achievement_category_id' => AchievementCategory::whereInternalName('general_flying')->first()->id,
|
||||
'icon' => 'standard_achievement.png',
|
||||
'has_page' => false,
|
||||
'sort_order' => 18,
|
||||
'progressive' => true,
|
||||
'threshold' => 1000000,
|
||||
]);
|
||||
|
||||
Achievement::whereInternalName('aircraft.smaller_manufacturer')->update([
|
||||
'difficulty_description' => 'General Aviation flights do not count. Only flights with an airline and flight number can earn this achievement',
|
||||
]);
|
||||
|
||||
foreach(User::all() as $user) {
|
||||
$user->calculateAchievements();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Airline;
|
||||
use App\Models\Country;
|
||||
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
|
||||
{
|
||||
Airline::create([
|
||||
'name' => 'Scoot',
|
||||
'IATA_code' => 'TR',
|
||||
'ICAO_code' => 'TGW',
|
||||
'internal_name' => 'scoot-new',
|
||||
'logo' => 'TR.png',
|
||||
'active' => true,
|
||||
'country_id' => Country::whereCode('SG')->first()->id,
|
||||
]);
|
||||
|
||||
Aircraft::where('manufacturer_code', 'ATR')
|
||||
->each(function ($aircraft) {
|
||||
$aircraft->update([
|
||||
'model_full_name' => str_replace('ATR-', '', $aircraft->model_full_name),
|
||||
]);
|
||||
});
|
||||
|
||||
Aircraft::where('manufacturer_code', 'AIRBUS')
|
||||
->each(function ($aircraft) {
|
||||
$aircraft->update([
|
||||
'model_full_name' => str_replace('A-', 'A', $aircraft->model_full_name),
|
||||
]);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use App\Models\FlightReason;
|
||||
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
|
||||
{
|
||||
FlightReason::where('name', 'Other')->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Airline;
|
||||
use App\Models\Country;
|
||||
use App\Models\UserFlight;
|
||||
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
|
||||
{
|
||||
Airline::create([
|
||||
'name' => 'Pacific Blue',
|
||||
'internal_name' => 'pacific-blue',
|
||||
'IATA_code' => 'DJ',
|
||||
'ICAO_code' => 'PBN',
|
||||
'active' => false,
|
||||
'logo' => 'pacific-blue.png',
|
||||
'country_id' => Country::where('code', 'NZ')->first()->id,
|
||||
]);
|
||||
|
||||
$flightIds = [326, 327];
|
||||
|
||||
UserFlight::whereIn('id', $flightIds)->update(['airline_id' => Airline::where('internal_name', 'pacific-blue')->first()->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
$table->string('distance_unit', 3)->default('km')->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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('airlines', function (Blueprint $table) {
|
||||
$table->renameColumn('IATA_code', 'iata_code');
|
||||
$table->renameColumn('ICAO_code', 'icao_code');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<?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
|
||||
{
|
||||
$teams = config('permission.teams');
|
||||
$tableNames = config('permission.table_names');
|
||||
$columnNames = config('permission.column_names');
|
||||
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||
$table->id(); // permission id
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['name', 'guard_name']);
|
||||
});
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||
$table->id(); // role id
|
||||
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||
}
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
if ($teams || config('permission.testing')) {
|
||||
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||
} else {
|
||||
$table->unique(['name', 'guard_name']);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||
});
|
||||
|
||||
app('cache')
|
||||
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||
->forget(config('permission.cache.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$tableNames = config('permission.table_names');
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||
|
||||
Schema::dropIfExists($tableNames['role_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['model_has_roles']);
|
||||
Schema::dropIfExists($tableNames['model_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['roles']);
|
||||
Schema::dropIfExists($tableNames['permissions']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$role = Role::create(['name' => 'admin']);
|
||||
$permission = Permission::create(['name' => 'reconcile_missing_liveries']);
|
||||
|
||||
$role->givePermissionTo($permission);
|
||||
|
||||
$user = User::whereName('Josh')->first();
|
||||
|
||||
$user->assignRole($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?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('ignored_missing_liveries', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('filename')->unique()->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ignored_missing_liveries');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
// database/migrations/xxxx_add_settings_to_users_table.php
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->json('settings')->nullable()->after('password');
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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('followees', function (Blueprint $table) {
|
||||
$table->boolean('verified')->default(true)->after('followee_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -23,7 +23,7 @@ class AirlinesSeeder extends Seeder
|
||||
$renames = [
|
||||
'airlineId' => 'id',
|
||||
'codeIataAirline' => 'IATA_code',
|
||||
'codeIcaoAirline' => 'ICAO_code',
|
||||
'codeIcaoAirline' => 'icao_code',
|
||||
'slug' => 'internal_name',
|
||||
'nameAirline' => 'name',
|
||||
'codeIso2Country' => 'country_code',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"@inertiajs/vue3": "^2.0.0",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
@@ -35,6 +36,6 @@
|
||||
"maplibre-gl": "^5.22.0",
|
||||
"vue-echarts": "^8.0.1",
|
||||
"vue3-apexcharts": "^1.11.1",
|
||||
"vuetify": "^4.0.5"
|
||||
"vuetify": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
|
||||
<path d="M0 0 C2.9375 1.1875 2.9375 1.1875 4.75 4.4375 C9.79823035 20.37928005 6.72721297 37.49490904 0.31640625 52.6015625 C-1.1920586 55.43046825 -2.98958993 57.74930004 -5.0625 60.1875 C-8.86859809 58.7179003 -10.2356926 56.80258531 -12.25 53.3125 C-13.01376953 52.01699219 -13.01376953 52.01699219 -13.79296875 50.6953125 C-18.9892567 40.43064523 -16.71207847 25.69760882 -13.26953125 15.1875 C-11.63832098 11.13322983 -9.74682264 7.63137374 -7.0625 4.1875 C-6.423125 3.341875 -5.78375 2.49625 -5.125 1.625 C-3.0625 0.1875 -3.0625 0.1875 0 0 Z " fill="#008AC1" transform="translate(68.0625,3.8125)"/>
|
||||
<path d="M0 0 C0.74765625 0.42410156 1.4953125 0.84820312 2.265625 1.28515625 C8.1047672 4.85021075 13.25197139 9.22303858 17 15 C17.62536267 17.68856707 17.47051231 19.23574018 17 22 C11.51599695 24.74200153 5.73329795 22.80754733 0.25 21 C-9.6545213 17.23543355 -20.88844139 11.00556137 -27 2 C-27.203125 -0.73828125 -27.203125 -0.73828125 -27 -3 C-17.88529518 -5.76816961 -8.10184736 -4.88852068 0 0 Z " fill="#E86533" transform="translate(100,72)"/>
|
||||
<path d="M0 0 C-0.83163971 5.91388239 -3.65841913 9.06469551 -8 13 C-16.04118869 18.78732929 -26.35309977 20.55877329 -36.11328125 19.73828125 C-42.39874117 18.60125883 -42.39874117 18.60125883 -45 16 C-44.91170833 12.64491643 -44.32277092 11.32277092 -41.9375 8.9375 C-30.1667114 1.17378837 -14.01371745 -4.05914574 0 0 Z " fill="#04B2C4" transform="translate(51,66)"/>
|
||||
<path d="M0 0 C2.9375 1.1875 2.9375 1.1875 4.75 4.4375 C9.42068654 19.18703645 6.95887585 34.84383074 1.9375 49.1875 C1.16416788 46.86750365 0.88730164 45.4248787 1.3984375 43.0078125 C2.05925736 39.55047959 2.10728024 36.26914473 2.125 32.75 C2.14626953 30.83380859 2.14626953 30.83380859 2.16796875 28.87890625 C1.94356734 25.28468029 1.24278856 22.52553042 -0.0625 19.1875 C-2.90784204 18.89121263 -2.90784204 18.89121263 -6.0625 20.1875 C-10.87111423 27.19688212 -12.176804 34.12015788 -12.625 42.4375 C-12.66818359 43.18257812 -12.71136719 43.92765625 -12.75585938 44.6953125 C-12.86146725 46.525849 -12.96244201 48.35665176 -13.0625 50.1875 C-17.40081636 45.84918364 -16.36582483 39.17469119 -16.3815918 33.39697266 C-16.21731375 22.78779035 -13.68360918 12.68210632 -7.0625 4.1875 C-6.423125 3.341875 -5.78375 2.49625 -5.125 1.625 C-3.0625 0.1875 -3.0625 0.1875 0 0 Z " fill="#39A6D4" transform="translate(68.0625,3.8125)"/>
|
||||
<path d="M0 0 C0.33 0.33 0.66 0.66 1 1 C0.84984669 9.25843232 -2.60481663 15.23535952 -8.08203125 21.30859375 C-13.72461166 26.69424405 -19.2219303 29.70746512 -27.0625 30.1875 C-27.701875 30.125625 -28.34125 30.06375 -29 30 C-30.31038168 26.06885495 -29.37989883 25.02867974 -27.5625 21.375 C-22.27720245 11.29152911 -12.82384281 -1.87014374 0 0 Z " fill="#633187" transform="translate(100,34)"/>
|
||||
<path d="M0 0 C5.93960036 -0.75824685 9.00354596 0.74419986 13.80078125 4.23828125 C21.18900343 10.15673277 27.59952269 17.19802259 29.875 26.625 C29.91625 27.40875 29.9575 28.1925 30 29 C25.27204456 30.44494818 21.58350797 29.69950295 17 28 C8.82293751 23.42685153 4.11457096 16.22914193 0 8 C-0.13415472 5.3276379 -0.04318541 2.67749512 0 0 Z " fill="#57A54A" transform="translate(23,32)"/>
|
||||
<path d="M0 0 C2.86880035 1.88824042 5.43872646 3.87745293 7 7 C7.56735892 11.65048294 7.4598631 15.09730577 5.3125 19.3125 C-0.12652788 25.6335324 -0.12652788 25.6335324 -3.95703125 26.3828125 C-7.26055142 26.5666819 -9.5828384 26.24601838 -12.4375 24.5625 C-15.07406785 20.23852873 -15.60234498 15.7648622 -14.5234375 10.8125 C-11.91798084 3.25381253 -8.54250005 -2.29990386 0 0 Z " fill="#F0AE49" transform="translate(64,66)"/>
|
||||
<path d="M0 0 C7.62760221 -0.71658058 12.84695266 0.32368402 19 5 C22.32385996 8.00860727 24.79551563 10.39820575 25.375 14.9375 C25.25125 15.618125 25.1275 16.29875 25 17 C17.26168246 17.89288279 11.38595099 15.35197521 5 11 C1.90389071 7.58204983 0 4.65967563 0 0 Z " fill="#DF3C2E" transform="translate(76,69)"/>
|
||||
<path d="M0 0 C-0.66386719 0.24234375 -1.32773437 0.4846875 -2.01171875 0.734375 C-9.11824782 3.35660009 -9.11824782 3.35660009 -15 8 C-15 9.32 -15 10.64 -15 12 C-7.83008688 15.58495656 3.87451725 12.9346496 11.12109375 10.65625 C11.74113281 10.4396875 12.36117187 10.223125 13 10 C7.94075638 15.78671656 -0.36338753 17.15878877 -7.6875 17.875 C-12.92104443 18.14290763 -17.06897905 17.76772449 -22 16 C-22.66 15.34 -23.32 14.68 -24 14 C-23.91170833 10.64491643 -23.32277092 9.32277092 -20.9375 6.9375 C-7.1842593 -2.13378642 -7.1842593 -2.13378642 0 0 Z " fill="#5EC4D8" transform="translate(30,68)"/>
|
||||
<path d="M0 0 C4.30954702 0.17238188 7.156053 0.69457019 10.3125 3.625 C14.1626862 7.89613989 15 12.33884306 15 18 C9.69453808 17.67845685 6.50165938 14.71781119 3 11 C0.58988665 7.19046599 -0.3975267 4.50530258 0 0 Z " fill="#018F4B" transform="translate(34,43)"/>
|
||||
<path d="M0 0 C0.763125 0.20625 1.52625 0.4125 2.3125 0.625 C2.04374573 7.07510247 -0.32163178 10.92172276 -4.6875 15.625 C-8.27533784 18.625 -8.27533784 18.625 -10.6875 18.625 C-10.43023307 16.72727809 -10.15606661 14.83184323 -9.875 12.9375 C-9.72417969 11.88175781 -9.57335937 10.82601562 -9.41796875 9.73828125 C-8.23699295 4.70492448 -6.01598249 -0.83943942 0 0 Z " fill="#403182" transform="translate(88.6875,43.375)"/>
|
||||
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C2.59865471 6.98654709 2.59865471 6.98654709 0.95703125 9.765625 C-0.83858185 11.93510476 -2.46143928 13.73071964 -5 15 C-8 15.1875 -8 15.1875 -11 15 C-11.66 14.34 -12.32 13.68 -13 13 C-13 11.68 -13 10.36 -13 9 C-10.85242436 7.21822021 -8.83417915 5.73979833 -6.5 4.25 C-5.87996094 3.83878906 -5.25992188 3.42757812 -4.62109375 3.00390625 C-3.08882829 1.99025371 -1.54541883 0.99348354 0 0 Z " fill="#E68434" transform="translate(66,70)"/>
|
||||
<path d="M0 0 C0.99 0.66 1.98 1.32 3 2 C1.50610707 5.83658866 -0.63623019 7.46185426 -4 9.75 C-5.3303125 10.67039062 -5.3303125 10.67039062 -6.6875 11.609375 C-9 13 -9 13 -11 13 C-11.33 13.66 -11.66 14.32 -12 15 C-12.62739631 6.0247473 -12.62739631 6.0247473 -10.125 1.9375 C-6.48014483 -1.3857503 -4.73559018 -1.06309167 0 0 Z " fill="#E5A037" transform="translate(64,66)"/>
|
||||
<path d="M0 0 C3.3 0 6.6 0 10 0 C8.515 1.485 8.515 1.485 7 3 C7.66 4.65 8.32 6.3 9 8 C9.66 8 10.32 8 11 8 C12.0625 9.8125 12.0625 9.8125 13 12 C12.67 12.99 12.34 13.98 12 15 C6.04495399 11.86348305 2.75924728 9.20830637 0 3 C0 2.01 0 1.02 0 0 Z " fill="#DF4A2F" transform="translate(76,69)"/>
|
||||
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.63016497 4.16115776 0.0109362 4.9927092 -3 7 C-3.70355115 9.57679229 -4.24517532 12.00489097 -4.6875 14.625 C-4.81705078 15.33140625 -4.94660156 16.0378125 -5.08007812 16.765625 C-5.39801563 18.50839354 -5.70049829 20.25396874 -6 22 C-7.65 22 -9.3 22 -11 22 C-12 19 -12 19 -10.99609375 16.328125 C-7.87593884 10.16601455 -4.7751598 5.00254836 0 0 Z " fill="#672F82" transform="translate(82,42)"/>
|
||||
<path d="M0 0 C0.99 0 1.98 0 3 0 C3.24492188 0.65226562 3.48984375 1.30453125 3.7421875 1.9765625 C6.57675185 8.60357847 9.43399165 11.54643077 15.890625 14.74609375 C18 16 18 16 19 19 C11.54445969 16.67014365 4.98955628 10.86272863 0 5 C-0.375 2.125 -0.375 2.125 0 0 Z " fill="#E88D38" transform="translate(73,69)"/>
|
||||
<path d="M0 0 C2.75 0.9375 2.75 0.9375 3.75 2.9375 C1.96190303 4.10963045 0.16940875 5.27505526 -1.625 6.4375 C-2.62273438 7.0871875 -3.62046875 7.736875 -4.6484375 8.40625 C-7.25 9.9375 -7.25 9.9375 -9.25 9.9375 C-8.95118318 6.45130377 -8.21472514 4.89113875 -6.0625 2.0625 C-3.25 -0.0625 -3.25 -0.0625 0 0 Z " fill="#E3BD38" transform="translate(61.25,65.0625)"/>
|
||||
<path d="M0 0 C0.99 0 1.98 0 3 0 C4.82421875 2.625 4.82421875 2.625 6.6875 6 C7.31011719 7.11375 7.93273437 8.2275 8.57421875 9.375 C9.04472656 10.24125 9.51523438 11.1075 10 12 C9.01 12.495 9.01 12.495 8 13 C4.72653393 9.90347804 2.02490044 7.04980088 0 3 C0 2.01 0 1.02 0 0 Z " fill="#DF5C30" transform="translate(76,69)"/>
|
||||
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C0.43754389 4.83511953 -2.10365784 7.10365784 -5 10 C-6.29519995 12.16620544 -6.29519995 12.16620544 -7 14 C-7.66 14 -8.32 14 -9 14 C-7.65070536 10.16336928 -5.92248975 7.34827431 -3.375 4.1875 C-2.42753906 3.00220703 -2.42753906 3.00220703 -1.4609375 1.79296875 C-0.97882813 1.20128906 -0.49671875 0.60960937 0 0 Z " fill="#862D84" transform="translate(82,42)"/>
|
||||
<path d="M0 0 C0.99 0 1.98 0 3 0 C4.32 2.97 5.64 5.94 7 9 C1.95652174 7.73913043 1.95652174 7.73913043 0 5 C-0.1875 2.3125 -0.1875 2.3125 0 0 Z " fill="#E17C35" transform="translate(73,69)"/>
|
||||
<path d="M0 0 C3.98069267 2.87494471 6.43571997 5.82117329 9 10 C8.67 10.66 8.34 11.32 8 12 C6.6547417 10.76284282 5.32412124 9.50975423 4 8.25 C3.2575 7.55390625 2.515 6.8578125 1.75 6.140625 C0 4 0 4 0 0 Z " fill="#71AD4A" transform="translate(41,41)"/>
|
||||
<path d="M0 0 C0.66 0 1.32 0 2 0 C1.5520266 2.31452922 1.09133539 4.62660039 0.625 6.9375 C0.36976562 8.22527344 0.11453125 9.51304688 -0.1484375 10.83984375 C-0.42945312 11.88269531 -0.71046875 12.92554688 -1 14 C-1.99 14.495 -1.99 14.495 -3 15 C-2.44854729 9.76119922 -1.57077907 5.02649303 0 0 Z " fill="#543082" transform="translate(79,48)"/>
|
||||
<path d="M0 0 C3.90288685 2.60192457 3.99523245 4.51719093 5 9 C5 10.32 5 11.64 5 13 C4.01 12.67 3.02 12.34 2 12 C1.34 8.04 0.68 4.08 0 0 Z " fill="#25954A" transform="translate(44,48)"/>
|
||||
<path d="M0 0 C1.32 0.33 2.64 0.66 4 1 C-0.455 4.465 -0.455 4.465 -5 8 C-5 5 -5 5 -2.75 2.25 C-1.8425 1.5075 -0.935 0.765 0 0 Z " fill="#E6D13D" transform="translate(58,65)"/>
|
||||
<path d="M0 0 C0.66 0.33 1.32 0.66 2 1 C1.67 3.31 1.34 5.62 1 8 C0.01 7.67 -0.98 7.34 -2 7 C-1.34 4.69 -0.68 2.38 0 0 Z " fill="#862D84" transform="translate(72,56)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 550 KiB |
|
Before Width: | Height: | Size: 560 KiB |
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 572 KiB |
|
Before Width: | Height: | Size: 486 KiB |
|
Before Width: | Height: | Size: 615 KiB |
|
Before Width: | Height: | Size: 639 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 494 KiB |
|
Before Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 551 KiB |
|
Before Width: | Height: | Size: 543 KiB |
|
Before Width: | Height: | Size: 524 KiB |
|
Before Width: | Height: | Size: 554 KiB |
|
Before Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 535 KiB |
|
Before Width: | Height: | Size: 557 KiB |