Added Notifications

This commit is contained in:
2026-06-14 16:04:01 +10:00
parent e24d3ceaec
commit a753bffaf8
49 changed files with 1118 additions and 381 deletions
+13
View File
@@ -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,
) {}
}
+58
View File
@@ -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();
}
}
+40 -25
View File
@@ -126,6 +126,18 @@ class FlightImportController extends Controller
return $airlines; 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) public function reconcile(Request $request)
{ {
$user = Auth::user(); $user = Auth::user();
@@ -133,35 +145,38 @@ class FlightImportController extends Controller
$flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('date', 'asc')->first(); $flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('date', 'asc')->first();
if (!$flightToReconcile) { if (!$flightToReconcile) {
return null; return to_route('import.fr24');
} }
$date = null; $date = $flightToReconcile->date
if ($flightToReconcile->date) { ? Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d')
$date = Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d'); : null;
}
$flight = [
return [ 'imported_flight_id' => $flightToReconcile->id,
'imported_flight_id' => $flightToReconcile->id, 'flight_classes' => $this->selectOptions(FlightClass::class),
'flight_classes' => $this->selectOptions(FlightClass::class), 'flight_reasons' => $this->selectOptions(FlightReason::class),
'flight_reasons' => $this->selectOptions(FlightReason::class), 'seat_types' => $this->selectOptions(SeatType::class),
'seat_types' => $this->selectOptions(SeatType::class), 'flight_number' => $flightToReconcile->flight_number ?? '',
'flight_number' => $flightToReconcile->flight_number ?? '', 'date' => $date ?? '',
'date' => $date ?? '', 'dep_time' => $this->formatTime($flightToReconcile->dep_time),
'dep_time' => $this->formatTime($flightToReconcile->dep_time), 'arr_time' => $this->formatTime($flightToReconcile->arr_time),
'arr_time' => $this->formatTime($flightToReconcile->arr_time), 'duration' => $this->formatTime($flightToReconcile->duration),
'duration' => $this->formatTime($flightToReconcile->duration), 'registration' => $flightToReconcile->registration ?? '',
'registration' => $flightToReconcile->registration ?? '', 'note' => $flightToReconcile->note ?? '',
'note' => $flightToReconcile->note ?? '', 'flight_class' => $flightToReconcile->flight_class !== null ? (int) $flightToReconcile->flight_class : null,
'flight_class' => $flightToReconcile->flight_class !== null ? (int) $flightToReconcile->flight_class : null, 'seat_type' => $flightToReconcile->seat_type !== null ? (int) $flightToReconcile->seat_type : null,
'seat_type' => $flightToReconcile->seat_type !== null ? (int) $flightToReconcile->seat_type : null, 'flight_reason' => $flightToReconcile->flight_reason !== null ? (int) $flightToReconcile->flight_reason : null,
'flight_reason' => $flightToReconcile->flight_reason !== null ? (int) $flightToReconcile->flight_reason : null, 'airline_options' => $this->getPossibleAirlines($flightToReconcile->airline ?? ''),
'airline_options' => $this->getPossibleAirlines($flightToReconcile->airline ?? ''), 'to_options' => $this->getPossibleAirports($flightToReconcile->to ?? ''),
'to_options' => $this->getPossibleAirports($flightToReconcile->to ?? ''), 'from_options' => $this->getPossibleAirports($flightToReconcile->from ?? ''),
'from_options' => $this->getPossibleAirports($flightToReconcile->from ?? ''), 'aircraft_options' => $this->getPossibleAircraft($flightToReconcile->aircraft ?? ''),
'aircraft_options' => $this->getPossibleAircraft($flightToReconcile->aircraft ?? ''),
]; ];
return Inertia::render('ReconcileFlight', [
'flight' => $flight,
'key' => $flight['imported_flight_id'],
]);
} }
public function save(Request $request) public function save(Request $request)
@@ -17,7 +17,7 @@ class FlightProfileController extends Controller
public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array { public function profileData(User $user, string $view, ?int $selectedFlightId = null) : array {
return [ return [
'user' => $user, 'user' => $user,
'canEdit' => auth()->check() && auth()->id() === $user->id, 'canEdit' => (auth()->check() && auth()->id() === $user->id) || auth()->user()->hasRole('admin'),
'initialView' => $view, 'initialView' => $view,
'selectedFlightId' => $selectedFlightId, 'selectedFlightId' => $selectedFlightId,
'flight_api_url' => self::getUserFlightApiURL($user), 'flight_api_url' => self::getUserFlightApiURL($user),
@@ -35,6 +35,8 @@ class HandleInertiaRequests extends Middleware
'logo_api_url' => config('app.logo_api_url'), 'logo_api_url' => config('app.logo_api_url'),
'auth' => [ 'auth' => [
'user' => $request->user(), 'user' => $request->user(),
'roles' => $request->user()?->getRoleNames() ?? [],
'permissions' => $request->user()?->getAllPermissions()->pluck('name') ?? [],
], ],
'achievement_notifications' => fn() => $request->user() 'achievement_notifications' => fn() => $request->user()
? $request->user() ? $request->user()
+14
View File
@@ -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'];
}
+2 -4
View File
@@ -10,8 +10,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use App\Traits\HasAchievements; use App\Traits\HasAchievements;
use App\Models\Notification;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
#[Fillable(['name', 'email', 'password', 'distance_unit'])] #[Fillable(['name', 'email', 'password', 'distance_unit'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
@@ -19,9 +19,7 @@ class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, HasAchievements, HasApiTokens; use HasFactory, HasAchievements, HasApiTokens, HasRoles;
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
+1 -1
View File
@@ -37,7 +37,7 @@ class UserFlightPolicy
*/ */
public function update(User $user, UserFlight $userFlight): bool public function update(User $user, UserFlight $userFlight): bool
{ {
return $user->id === $userFlight->user_id; return $user->id === $userFlight->user_id || $user->hasRole('admin');
} }
/** /**
@@ -1,26 +1,29 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Services;
use Illuminate\Http\Request; use App\DTOs\MissingLivery;
use App\Models\IgnoredMissingLivery;
use App\Models\UserFlight;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Collection;
class AdminToolsController extends Controller class AdminService
{ {
function missingLiveries(){ /** @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(public_path('img/liveries/generated/*')))
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
->toArray();*/
$existingFiles = collect(glob(Storage::disk('local')->path('images/liveries').'/*.png')) $existingFiles = collect(glob(Storage::disk('local')->path('images/liveries').'/*.png'))
->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME)) ->map(fn ($path) => pathinfo($path, PATHINFO_FILENAME))
->toArray(); ->toArray();
$combos = UserFlight::with(['aircraft', 'airline'])
$combos = \App\Models\UserFlight::with(['aircraft', 'airline'])
->select('airline_id', 'aircraft_id') ->select('airline_id', 'aircraft_id')
->whereNotNull('airline_id') ->whereNotNull('airline_id')
->whereNotNull('aircraft_id') ->whereNotNull('aircraft_id')
@@ -31,14 +34,15 @@ class AdminToolsController extends Controller
'airline_name' => $flight->airline->name, 'airline_name' => $flight->airline->name,
'aircraft_display_name' => $flight->aircraft->display_name, 'aircraft_display_name' => $flight->aircraft->display_name,
'filename' => $flight->airline->internal_name . '_' . $flight->aircraft->designator, '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)) ->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') ->sortBy('airline_name')
->values(); ->values();
return response()->json([
'count' => $combos->count(),
'liveries' => $combos,
]);
} }
} }
+5 -1
View File
@@ -16,7 +16,11 @@ return Application::configure(basePath: dirname(__DIR__))
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
]); ]);
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
]);
// //
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
+1
View File
@@ -15,6 +15,7 @@
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"spatie/laravel-permission": "^8.0",
"tightenco/ziggy": "^2.0" "tightenco/ziggy": "^2.0"
}, },
"require-dev": { "require-dev": {
Generated
+149 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "0e560320885031dd36bb08bb44fe05d4", "content-hash": "2fab3a0703fff56cedb9d4c9b650e2fc",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@@ -3433,6 +3433,154 @@
}, },
"time": "2025-12-14T04:43:48+00:00" "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", "name": "symfony/clock",
"version": "v8.0.0", "version": "v8.0.0",
+219
View File
@@ -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,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,24 @@
<?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
{
//
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{
label: string;
count: number;
weeklyGrowth: number;
icon: string;
}>();
</script>
<template>
<v-card class="pa-5 d-flex align-center justify-space-between">
<div>
<div class="text-caption text-medium-emphasis">{{ label }}</div>
<div class="text-h5 font-weight-medium">{{ count }}</div>
</div>
<div class="text-caption "><slot/></div>
<div class="text-right">
<div class="d-flex align-center justify-end ga-1">
<v-icon :icon="icon" color="success" size="20" />
<span class="text-h6 font-weight-medium text-success">+{{ weeklyGrowth }}</span>
</div>
<div class="text-caption text-disabled">new this week </div>
</div>
</v-card>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { MissingLivery } from "@/Types/types";
import {router} from "@inertiajs/vue3";
import CopyButton from "@/Components/FlightsGoneBy/CopyButton.vue";
const props = defineProps<{
livery: MissingLivery;
}>();
const ignore = () => {
router.post(route('admin.ignore-missing-livery'), { filename: props.livery.filename });
};
</script>
<template>
<v-card flat rounded="lg" class="mb-2 px-4 py-3">
<v-row align="center" no-gutters>
<v-col cols="12" sm="4">
<div class="d-flex align-center ga-1 text-medium-emphasis" style="font-size: 11px;">
Airline
</div>
<div style="font-size: 12px;" class="font-weight-medium">{{ livery.airline_name }}</div>
</v-col>
<v-col cols="12" sm="4">
<div class="d-flex align-center ga-1 text-medium-emphasis" style="font-size: 11px;">
Aircraft
<CopyButton :text="livery.clipboard_text" />
</div>
<div style="font-size: 12px;" class="font-weight-medium">{{ livery.aircraft_display_name }}</div>
</v-col>
<v-col cols="12" sm="4">
<div class="d-flex align-center ga-1 text-medium-emphasis" style="font-size: 11px;">
Filename
<CopyButton :text="livery.filename" />
</div>
<div style="font-size: 12px;" class="font-weight-medium">{{ livery.filename }}</div>
</v-col>
</v-row>
<v-row no-gutters class="mt-2" justify="end">
<v-btn
size="x-small"
variant="tonal"
color="error"
prepend-icon="mdi-eye-off"
@click="ignore"
>
Ignore
</v-btn>
</v-row>
</v-card>
</template>
@@ -4,6 +4,7 @@ import type { CodeType } from '@/Composables/useAlphabetAirlines'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue' import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue' import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue"; import AirlineLogo from "@/Components/FlightsGoneBy/AirlineLogo.vue";
import {computed} from "vue";
const props = defineProps<{ const props = defineProps<{
letters: string[] letters: string[]
@@ -50,8 +51,8 @@ function isHighlighted({ firstYear }: AirlineEntry): boolean {
return props.selectedYear !== null && firstYear === props.selectedYear return props.selectedYear !== null && firstYear === props.selectedYear
} }
function toBBCode(): string { const bbCode = computed(() =>
return props.letters props.letters
.map(letter => { .map(letter => {
const entries = airlineEntriesForLetter(letter) const entries = airlineEntriesForLetter(letter)
if (!entries.length) return letter if (!entries.length) return letter
@@ -65,9 +66,9 @@ function toBBCode(): string {
.join(', ') .join(', ')
}) })
.join('\n') .join('\n')
} )
defineExpose({ toBBCode }) defineExpose({ bbCode })
</script> </script>
<template> <template>
@@ -3,6 +3,7 @@ import {Airport, Flight} from '@/Types/types'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue' import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue' import InlineBadge from '@/Components/FlightsGoneBy/InlineBadge.vue'
import AirportToolTip from '@/Components/FlightsGoneBy/AirportToolTip.vue' import AirportToolTip from '@/Components/FlightsGoneBy/AirportToolTip.vue'
import {computed} from "vue";
type CodeType = 'iata' | 'icao' type CodeType = 'iata' | 'icao'
@@ -50,8 +51,8 @@ function isHighlighted({ firstYear }: AirportEntry): boolean {
return props.selectedYear !== null && firstYear === props.selectedYear return props.selectedYear !== null && firstYear === props.selectedYear
} }
function toBBCode(): string { const bbCode = computed(() =>
return props.letters props.letters
.map(letter => { .map(letter => {
const entries = airportEntriesForLetter(letter) const entries = airportEntriesForLetter(letter)
if (!entries.length) return letter if (!entries.length) return letter
@@ -64,9 +65,9 @@ function toBBCode(): string {
.join(', ') .join(', ')
}) })
.join('\n') .join('\n')
} )
defineExpose({ toBBCode }) defineExpose({ bbCode })
</script> </script>
<template> <template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { ref } from "vue";
import { copyToClipboard } from "@/Composables/useClipboard";
const props = defineProps<{
text: string;
title?: string;
size?: string;
}>();
const copied = ref(false);
const handleCopy = async () => {
await copyToClipboard(props.text);
copied.value = true;
setTimeout(() => copied.value = false, 1500);
};
</script>
<template>
<v-btn
:title="title"
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
:size="size ?? 'x-small'"
:variant="copied ? 'tonal' : 'plain'"
:color="copied ? 'success' : undefined"
density="compact"
@click="handleCopy"
/>
</template>
@@ -38,7 +38,8 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<template v-else> <template v-else>
<Link :href="route('flights.add')" class="nav-link">Add Flight</Link> <Link :href="route('flights.add')" class="nav-link">Add Flight</Link>
<Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link">Profile</Link> <Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link">Profile</Link>
<Link href="/feed" class="nav-link">Feed</Link> <Link :href="route('feed')" class="nav-link">Feed</Link>
<Link v-if="props.auth.roles.includes('admin')" :href="route('admin.dashboard')" class="nav-link">Admin</Link>
<div class="dropdown" ref="dropdownRef"> <div class="dropdown" ref="dropdownRef">
<button class="nav-link dropdown-trigger" @click.stop="dropdownOpen = !dropdownOpen"> <button class="nav-link dropdown-trigger" @click.stop="dropdownOpen = !dropdownOpen">
@@ -73,7 +74,7 @@ onUnmounted(() => document.removeEventListener('click', handleClickOutside))
<span class="nav-greeting">Welcome, {{ props.auth.user.name }}</span> <span class="nav-greeting">Welcome, {{ props.auth.user.name }}</span>
<Link :href="route('flights.add')" class="nav-link" @click="menuOpen = false">Add Flight</Link> <Link :href="route('flights.add')" class="nav-link" @click="menuOpen = false">Add Flight</Link>
<Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link" @click="menuOpen = false">Profile</Link> <Link :href="route('profile.view', { user: props.auth.user.name })" class="nav-link" @click="menuOpen = false">Profile</Link>
<Link href="/feed" class="nav-link nav-link--highlight" @click="menuOpen = false">Feed</Link> <Link :href="route('feed')" class="nav-link nav-link" @click="menuOpen = false">Feed</Link>
<Link :href="route('import.fr24')" class="nav-link" @click="menuOpen = false">Import from FR24</Link> <Link :href="route('import.fr24')" class="nav-link" @click="menuOpen = false">Import from FR24</Link>
<div class="dropdown-divider" /> <div class="dropdown-divider" />
<button class="nav-link nav-link--danger" @click="logout">Log Out</button> <button class="nav-link nav-link--danger" @click="logout">Log Out</button>
@@ -115,9 +116,10 @@ header {
/* Shared nav link base */ /* Shared nav link base */
.nav-link { .nav-link {
display: inline-flex; display: flex;
align-items: center; align-items: center;
gap: 0.3em; gap: 0.3em;
height: 100%;
padding: 0.3em 0.75em; padding: 0.3em 0.75em;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
+15
View File
@@ -0,0 +1,15 @@
export const copyToClipboard = (text: string): Promise<void> => {
if (navigator.clipboard) {
return navigator.clipboard.writeText(text);
}
return new Promise((resolve) => {
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
resolve();
});
};
@@ -1,198 +0,0 @@
<script setup>
import { ref } from 'vue';
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import NavLink from '@/Components/NavLink.vue';
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
import { Link } from '@inertiajs/vue3';
const showingNavigationDropdown = ref(false);
</script>
<template>
<div>
<div class="min-h-screen bg-gray-100">
<nav
class="border-b border-gray-100 bg-white"
>
<!-- Primary Navigation Menu -->
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<!-- Logo -->
<div class="flex shrink-0 items-center">
<Link :href="route('dashboard')">
<ApplicationLogo
class="block h-9 w-auto fill-current text-gray-800"
/>
</Link>
</div>
<!-- Navigation Links -->
<div
class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"
>
<NavLink
:href="route('dashboard')"
:active="route().current('dashboard')"
>
Dashboard
</NavLink>
</div>
</div>
<div class="hidden sm:ms-6 sm:flex sm:items-center">
<!-- Settings Dropdown -->
<div class="relative ms-3">
<Dropdown align="right" width="48">
<template #trigger>
<span class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center rounded-md border border-transparent bg-white px-3 py-2 text-sm font-medium leading-4 text-gray-500 transition duration-150 ease-in-out hover:text-gray-700 focus:outline-none"
>
{{ $page.props.auth.user.name }}
<svg
class="-me-0.5 ms-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</span>
</template>
<template #content>
<DropdownLink
:href="route('profile.edit')"
>
Profile
</DropdownLink>
<DropdownLink
:href="route('logout')"
method="post"
as="button"
>
Log Out
</DropdownLink>
</template>
</Dropdown>
</div>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button
@click="
showingNavigationDropdown =
!showingNavigationDropdown
"
class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 transition duration-150 ease-in-out hover:bg-gray-100 hover:text-gray-500 focus:bg-gray-100 focus:text-gray-500 focus:outline-none"
>
<svg
class="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
:class="{
hidden: showingNavigationDropdown,
'inline-flex':
!showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
:class="{
hidden: !showingNavigationDropdown,
'inline-flex':
showingNavigationDropdown,
}"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Responsive Navigation Menu -->
<div
:class="{
block: showingNavigationDropdown,
hidden: !showingNavigationDropdown,
}"
class="sm:hidden"
>
<div class="space-y-1 pb-3 pt-2">
<ResponsiveNavLink
:href="route('dashboard')"
:active="route().current('dashboard')"
>
Dashboard
</ResponsiveNavLink>
</div>
<!-- Responsive Settings Options -->
<div
class="border-t border-gray-200 pb-1 pt-4"
>
<div class="px-4">
<div
class="text-base font-medium text-gray-800"
>
{{ $page.props.auth.user.name }}
</div>
<div class="text-sm font-medium text-gray-500">
{{ $page.props.auth.user.email }}
</div>
</div>
<div class="mt-3 space-y-1">
<ResponsiveNavLink :href="route('profile.edit')">
Profile
</ResponsiveNavLink>
<ResponsiveNavLink
:href="route('logout')"
method="post"
as="button"
>
Log Out
</ResponsiveNavLink>
</div>
</div>
</div>
</nav>
<!-- Page Heading -->
<header
class="bg-white shadow"
v-if="$slots.header"
>
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main>
<slot />
</main>
</div>
</div>
</template>
-22
View File
@@ -1,22 +0,0 @@
<script setup>
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import { Link } from '@inertiajs/vue3';
</script>
<template>
<div
class="flex min-h-screen flex-col items-center bg-gray-100 pt-6 sm:justify-center sm:pt-0"
>
<div>
<Link href="/">
<ApplicationLogo class="h-20 w-20 fill-current text-gray-500" />
</Link>
</div>
<div
class="mt-6 w-full overflow-hidden bg-white px-6 py-4 shadow-md sm:max-w-md sm:rounded-lg"
>
<slot />
</div>
</div>
</template>
+14 -8
View File
@@ -16,15 +16,21 @@ const achievementSound = new Audio('/sounds/seatBelt.wav')
function handleNewNotifications(notifications: Notification[]) { function handleNewNotifications(notifications: Notification[]) {
if (!notifications?.length) return if (!notifications?.length) return
const unseen = notifications.filter(n => !seenNotificationIds.value.has(n.id))
if (!unseen.length) return const newToasts: Notification[] = []
unseen.forEach(n => seenNotificationIds.value.add(n.id)) for (const n of notifications) {
activeToasts.value.push(...unseen) if (!seenNotificationIds.value.has(n.id)) {
seenNotificationIds.value.add(n.id)
newToasts.push(n)
}
}
if (!newToasts.length) return
activeToasts.value.push(...newToasts)
achievementSound.play().catch(() => {}) achievementSound.play().catch(() => {})
} }
// ── Toasts ──────────────────────────────────────────────────────────────────── // ── Toasts ────────────────────────────────────────────────────────────────────
const activeToasts = ref<Notification[]>([]) const activeToasts = ref<Notification[]>([])
@@ -47,13 +53,13 @@ router.on('success', (event) => {
<template> <template>
<Radar> <Radar>
<div class="layoutContainer"> <div class="layoutContainer">
<MainHeader :key="transitionKey" /> <MainHeader :key="`header-${transitionKey}`" />
<Transition name="fade" mode="out-in"> <Transition name="fade" mode="out-in">
<main id="pageContainer" :key="transitionKey"> <main id="pageContainer" :key="`main-${transitionKey}`">
<slot /> <slot />
</main> </main>
</Transition> </Transition>
<MainFooter :key="transitionKey" /> <MainFooter :key="`footer-${transitionKey}`" />
</div> </div>
<div class="toast-stack"> <div class="toast-stack">
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import MainLayout from "@/Layouts/MainLayout.vue";
import AdminSidebar from "@/Pages/Admin/AdminSidebar.vue";
import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue";
import {Head} from "@inertiajs/vue3";
defineProps<{
title: string;
missingLiveryCount: number;
}>()
</script>
<template>
<MainLayout>
<Head :title="title" />
<div class="admin-container">
<AdminSidebar :missingLiveryCount="missingLiveryCount" />
<div class="admin-content">
<GlassBox class="admin-page" :title="title">
<slot />
</GlassBox>
</div>
</div>
</MainLayout>
</template>
<style scoped>
.admin-container {
display: flex;
flex-direction: row;
width: 100%;
min-height: 90dvh;
/* Override MainLayout's centred main — fill the full height */
align-self: stretch;
align-items: stretch;
}
.admin-content {
display: flex;
flex-direction: column;
align-items: center;;
flex: 1 1 auto;
padding: 2rem;
overflow-y: auto;
min-width: 0;
}
.admin-page {
width: 100%;
max-width: 1200px;
min-height: 50%;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import {Link} from "@inertiajs/vue3";
import {number} from "echarts";
defineProps<{
missingLiveryCount: number;
}>()
</script>
<template>
<aside class="glass admin-sidebar">
<div class="sidebar-title">Admin</div>
<Link :href="route('admin.dashboard')" class="sidebar-link">
<v-icon icon="mdi-chart-line" size="18" />
Dashboard
</Link>
<Link :href="route('admin.reconcile-missing-liveries')" class="sidebar-link">
<v-icon icon="mdi-airplane-takeoff" size="18" />
Reconcile Missing Liveries
<v-chip size="x-small" color="error" class="ml-1">{{ missingLiveryCount }}</v-chip>
</Link>
</aside>
</template>
<style scoped>
.admin-sidebar {
width: 400px;
flex-shrink: 0;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
/* no height here */
}
.sidebar-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text);
opacity: 0.5;
padding: 0 0.5rem 0.75rem;
margin-bottom: 0.5rem;
}
.sidebar-link {
display: flex;
width: 100%;
height: 2.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
color: var(--text);
text-decoration: none;
transition: background 0.15s ease;
}
.sidebar-link:hover {
color: var(--accent);
background: rgba(56, 189, 248, 0.07);
}
.sidebar-link.active {
font-weight: 500;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import AdminLayout from "@/Pages/Admin/AdminLayout.vue";
import GrowthCard from "@/Components/Admin/GrowthCard.vue";
defineOptions({ layout: AdminLayout });
defineProps<{
userCount: number,
oneWeekUserGrowth: number,
flightCount: number,
oneWeekFlightGrowth: number,
latestUser:string
}>()
</script>
<template>
<v-container>
<v-row>
<v-col cols="12" sm="6">
<GrowthCard label="Total Users" :count="userCount" :weekly-growth="oneWeekUserGrowth" icon="mdi-account">
Latest User: {{ latestUser }}
</GrowthCard>
</v-col>
<v-col cols="12" sm="6">
<GrowthCard label="Total Flights" :count="flightCount" :weekly-growth="oneWeekFlightGrowth" icon="mdi-airplane" />
</v-col>
</v-row>
</v-container>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import GlassBox from "@/Components/FlightsGoneBy/GlassBox.vue";
import { Head } from "@inertiajs/vue3";
import {MissingLivery} from "@/Types/types";
import MissingLiveryCard from "@/Components/Admin/MissingLiveryCard.vue";
import AdminLayout from "@/Pages/Admin/AdminLayout.vue";
import Panel from "@/Components/FlightsGoneBy/Panels/Panel.vue";
defineOptions({ layout: AdminLayout });
defineProps<{
missingLiveries: MissingLivery[]
}>()
</script>
<template>
<MissingLiveryCard v-for="livery in missingLiveries" :key="livery.filename" :livery="livery" />
</template>
+1 -1
View File
@@ -10,7 +10,7 @@ import { router } from "@inertiajs/vue3";
defineOptions({ layout: MainLayout }); defineOptions({ layout: MainLayout });
const page = usePage<SharedProps>(); const page = usePage<SharedProps>();
const name = computed(() => page?.props?.auth?.user?.name || 'there'); const name = computed(() => page?.props?.auth?.user?.name || 'mate');
</script> </script>
<template> <template>
@@ -7,6 +7,8 @@ import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue' import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue' import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -7,7 +7,7 @@ import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue' import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue' import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue' import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types' import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue' import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineOptions({ inheritAttrs: false })
defineProps<{ defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types' import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue' import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineOptions({ inheritAttrs: false })
defineProps<{ defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types' import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue' import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineOptions({ inheritAttrs: false })
defineProps<{ defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types' import { Achievement, Airline, Alliance, Flight, User } from '@/Types/types'
import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue' import AllianceChallenge from '@/Components/FlightsGoneBy/AllianceChallenge.vue'
defineOptions({ inheritAttrs: false })
defineProps<{ defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -7,7 +7,7 @@ import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue' import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue' import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue' import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -6,7 +6,7 @@ import Panel from '@/Components/FlightsGoneBy/Panels/Panel.vue'
import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue' import PanelHeader from '@/Components/FlightsGoneBy/Panels/PanelHeader.vue'
import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue' import BadgeTable from '@/Components/FlightsGoneBy/GenericBadgeTable.vue'
import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue' import FlightBadge from '@/Components/FlightsGoneBy/FlightBadge.vue'
defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -6,7 +6,9 @@ import PanelSubHeader from '@/Components/FlightsGoneBy/Panels/PanelSubHeader.vue
import AirlineAlphabetTable from '@/Components/FlightsGoneBy/AirlineAlphabetTable.vue' import AirlineAlphabetTable from '@/Components/FlightsGoneBy/AirlineAlphabetTable.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useAlphabetAirlines, type CodeType } from '@/Composables/useAlphabetAirlines' import { useAlphabetAirlines, type CodeType } from '@/Composables/useAlphabetAirlines'
import {copyToClipboard} from "@/Composables/useClipboard";
import CopyButton from "@/Components/FlightsGoneBy/CopyButton.vue";
defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
@@ -47,28 +49,7 @@ const selectedYear = ref<number | null>(
// Copy BBCode // Copy BBCode
const airlineTable = ref<InstanceType<typeof AirlineAlphabetTable> | null>(null) const airlineTable = ref<InstanceType<typeof AirlineAlphabetTable> | null>(null)
const copied = ref(false)
async function copyBBCode() {
const text = airlineTable.value?.toBBCode()
if (text == null) return
try {
await navigator.clipboard.writeText(text)
} catch {
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
document.body.appendChild(el)
el.focus()
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
</script> </script>
<template> <template>
@@ -133,13 +114,10 @@ async function copyBBCode() {
class="year-select" class="year-select"
/> />
<v-btn <CopyButton
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
:color="copied ? 'success' : undefined"
density="compact"
variant="text"
title="Copy as BBCode" title="Copy as BBCode"
@click="copyBBCode" size="large"
:text="airlineTable?.bbCode ?? ''"
/> />
</div> </div>
</div> </div>
@@ -7,7 +7,8 @@ import AlphabetTable from '@/Components/FlightsGoneBy/AlphabetTable.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useAlphabetFlights, type CodeType } from '@/Composables/useAlphabetFlights' import { useAlphabetFlights, type CodeType } from '@/Composables/useAlphabetFlights'
import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue"; import InlineBadge from "@/Components/FlightsGoneBy/InlineBadge.vue";
import CopyButton from "@/Components/FlightsGoneBy/CopyButton.vue";
defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
@@ -45,27 +46,6 @@ const selectedYear = ref<number | null>(
const alphabetTable = ref<InstanceType<typeof AlphabetTable> | null>(null) const alphabetTable = ref<InstanceType<typeof AlphabetTable> | null>(null)
const copied = ref(false) const copied = ref(false)
async function copyBBCode() {
const text = alphabetTable.value?.toBBCode()
if (text == null) return
try {
await navigator.clipboard.writeText(text)
} catch {
// Fallback for non-HTTPS or browsers that block clipboard API
const el = document.createElement('textarea')
el.value = text
el.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0'
document.body.appendChild(el)
el.focus()
el.select()
document.execCommand('copy')
document.body.removeChild(el)
}
copied.value = true
setTimeout(() => (copied.value = false), 2000)
}
</script> </script>
<template> <template>
@@ -124,13 +104,10 @@ async function copyBBCode() {
class="year-select" class="year-select"
/> />
<v-btn <CopyButton
:icon="copied ? 'mdi-check' : 'mdi-content-copy'"
:color="copied ? 'success' : undefined"
density="compact"
variant="text"
title="Copy as BBCode" title="Copy as BBCode"
@click="copyBBCode" size="large"
:text="alphabetTable?.bbCode ?? ''"
/> />
</div> </div>
</div> </div>
@@ -12,7 +12,7 @@ import {useRegionFlights} from "@/Composables/useRegionFlights";
import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue"; import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
defineOptions({ layout: MainLayout }) defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -12,7 +12,7 @@ import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
import Brazil from "@/Components/Maps/Brazil.vue"; import Brazil from "@/Components/Maps/Brazil.vue";
defineOptions({ layout: MainLayout }) defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -13,7 +13,7 @@ import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
import Canada from "@/Components/Maps/Canada.vue"; import Canada from "@/Components/Maps/Canada.vue";
defineOptions({ layout: MainLayout }) defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -12,7 +12,7 @@ import China from "@/Components/Maps/China.vue";
import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue"; import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
defineOptions({ layout: MainLayout }) defineOptions({ inheritAttrs: false })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
@@ -11,9 +11,8 @@ import FlightRegionTable from "@/Components/FlightsGoneBy/FlightRegionTable.vue"
import {useRegionFlights} from "@/Composables/useRegionFlights"; import {useRegionFlights} from "@/Composables/useRegionFlights";
import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue"; import RegionLegend from "@/Components/FlightsGoneBy/Panels/RegionLegend.vue";
import USA from "@/Components/Maps/USA.vue"; import USA from "@/Components/Maps/USA.vue";
defineOptions({ inheritAttrs: false })
defineOptions({ layout: MainLayout })
const props = defineProps<{ const props = defineProps<{
achievement: Achievement achievement: Achievement
user: User user: User
+9
View File
@@ -62,6 +62,8 @@ export type SharedProps = import('@inertiajs/core').PageProps & {
auth: { auth: {
user: User | null user: User | null
isLoggedIn: boolean isLoggedIn: boolean
roles: string[];
permissions: string[];
}, },
logo_api_url: string logo_api_url: string
achievement_notifications: Notification[] achievement_notifications: Notification[]
@@ -259,6 +261,13 @@ export interface Flight {
livery_url?: string livery_url?: string
} }
export interface MissingLivery {
airline_name: string;
aircraft_display_name: string;
filename: string;
clipboard_text: string;
}
declare module '@inertiajs/vue3' { declare module '@inertiajs/vue3' {
interface PageProps extends SharedProps {} interface PageProps extends SharedProps {}
} }
+11 -23
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\AchievementController; use App\Http\Controllers\AchievementController;
use App\Http\Controllers\AdminController;
use App\Http\Controllers\AdminToolsController; use App\Http\Controllers\AdminToolsController;
use App\Http\Controllers\Api\AircraftApiController; use App\Http\Controllers\Api\AircraftApiController;
use App\Http\Controllers\Api\AirlineApiController; use App\Http\Controllers\Api\AirlineApiController;
@@ -40,14 +41,13 @@ Route::domain(config('app.domain'))->group(
return Inertia::render('Dashboard'); return Inertia::render('Dashboard');
})->middleware(['auth', 'verified'])->name('dashboard'); })->middleware(['auth', 'verified'])->name('dashboard');
Route::middleware('auth')->group(function () { Route::middleware(['auth', 'role:admin'])->prefix('admin')->name('admin.')->group(function () {
Route::get('/import/fr24', function () { Route::get('/', [AdminController::class, 'dashboard'])->name('dashboard');
if (Auth::user()->importedFlights()->exists()) { Route::get('/missing-liveries', [AdminController::class, 'reconcileMissingLiveries'])->name('reconcile-missing-liveries')->middleware('permission:reconcile_missing_liveries');
return redirect()->route('reconcile'); Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery');
} });
return Inertia::render('Fr24Import');
})->name('import.fr24');
Route::middleware('auth')->group(function () {
Route::post('/flights', [FlightController::class, 'store'])->name('flights.store'); Route::post('/flights', [FlightController::class, 'store'])->name('flights.store');
Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add'); Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add');
Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit'); Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit');
@@ -55,19 +55,8 @@ Route::domain(config('app.domain'))->group(
Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete'); Route::delete('/flights/{flight}/{referrer?}', [FlightController::class, 'delete'])->name('flights.delete');
Route::get('/import/fr24', [FlightImportController::class, 'showFr24Import'])->name('import.fr24');
Route::get('/reconcile', function () { Route::get('/reconcile', [FlightImportController::class, 'reconcile'])->name('reconcile');;
$flight = new FlightImportController()->reconcile(request());
if (!$flight) {
return to_route('import.fr24');
}
return Inertia::render('ReconcileFlight', [
'flight' => $flight,
'key' => $flight['imported_flight_id'],
]);
})->name('reconcile');
Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup'); Route::get('/flights/lookup', [FlightController::class, 'lookup'])->name('flights.lookup');
Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store'); Route::post('/flights/import', [FlightImportController::class, 'store'])->name('flights.import.store');
@@ -88,13 +77,12 @@ Route::domain(config('app.domain'))->group(
Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft'); Route::get('/search/aircraft', [SearchController::class, 'aircraft'])->name('search.aircraft');
Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports'); Route::get('/search/airports', [SearchController::class, 'airports'])->name('search.airports');
//@Todo: Move to API
Route::get('/data/user/{username}/flights', [UserApiController::class, 'flights']); Route::get('/data/user/{username}/flights', [UserApiController::class, 'flights']);
Route::get('/missing-liveries', [AdminToolsController::class, 'missingLiveries'])->name('admin.tools.missing-liveries');
Route::get('/u/{user}', [FlightProfileController::class, 'view'])->name('profile.view'); Route::get('/u/{user}', [FlightProfileController::class, 'view'])->name('profile.view');
Route::get('/u/{user}/map', [FlightProfileController::class, 'map'])->name('profile.map'); Route::get('/u/{user}/map', [FlightProfileController::class, 'map'])->name('profile.map');
Route::get('/u/{user}/departure-board/{flight?}', [FlightProfileController::class, 'departureBoard']) Route::get('/u/{user}/departure-board/{flight?}', [FlightProfileController::class, 'departureBoard'])->name('profile.departure-board');
->name('profile.departure-board');
Route::get('/u/{user}/boarding-passes', [FlightProfileController::class, 'boardingPasses'])->name('profile.boarding-passes'); Route::get('/u/{user}/boarding-passes', [FlightProfileController::class, 'boardingPasses'])->name('profile.boarding-passes');
Route::get('/u/{user}/achievements', [AchievementController::class, 'index'])->name('profile.achievements'); Route::get('/u/{user}/achievements', [AchievementController::class, 'index'])->name('profile.achievements');
Route::get('/u/{user}/achievement/{achievement}', [AchievementController::class, 'specific'])->name('profile.achievement'); Route::get('/u/{user}/achievement/{achievement}', [AchievementController::class, 'specific'])->name('profile.achievement');