]> git.localhorst.tv Git - alttp.git/commitdiff
basic result display
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 10 Mar 2022 22:53:35 +0000 (23:53 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 10 Mar 2022 22:53:35 +0000 (23:53 +0100)
37 files changed:
app/Http/Controllers/DiscordController.php [new file with mode: 0644]
app/Http/Controllers/RoundController.php
app/Http/Controllers/TournamentController.php
app/Models/Round.php
app/Models/User.php
app/Policies/TournamentPolicy.php
composer.json
composer.lock
config/larascord.php
database/migrations/2022_03_09_232126_optional_round_seed.php [new file with mode: 0644]
database/migrations/2022_03_10_161017_optional_user_email.php [new file with mode: 0644]
resources/js/components/App.js
resources/js/components/common/ErrorBoundary.js [new file with mode: 0644]
resources/js/components/common/ErrorMessage.js [new file with mode: 0644]
resources/js/components/common/Header.js
resources/js/components/common/Loading.js [new file with mode: 0644]
resources/js/components/pages/NotFound.js [new file with mode: 0644]
resources/js/components/pages/Tournament.js [new file with mode: 0644]
resources/js/components/participants/List.js [new file with mode: 0644]
resources/js/components/results/Item.js [new file with mode: 0644]
resources/js/components/results/List.js [new file with mode: 0644]
resources/js/components/rounds/Item.js [new file with mode: 0644]
resources/js/components/rounds/List.js [new file with mode: 0644]
resources/js/components/tournament/Detail.js [new file with mode: 0644]
resources/js/components/users/Box.js [new file with mode: 0644]
resources/js/helpers/Participant.js [new file with mode: 0644]
resources/js/helpers/Result.js [new file with mode: 0644]
resources/js/helpers/User.js [new file with mode: 0644]
resources/js/helpers/permissions.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/sass/app.scss
resources/sass/common.scss
resources/sass/participants.scss [new file with mode: 0644]
resources/sass/rounds.scss [new file with mode: 0644]
resources/sass/users.scss [new file with mode: 0644]
routes/api.php
routes/web.php

diff --git a/app/Http/Controllers/DiscordController.php b/app/Http/Controllers/DiscordController.php
new file mode 100644 (file)
index 0000000..6f9c03d
--- /dev/null
@@ -0,0 +1,160 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\User;
+use App\Providers\RouteServiceProvider;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Http;
+
+class DiscordController extends \Jakyeru\Larascord\Http\Controllers\DiscordController
+{
+
+       /**
+        * Handles the Discord OAuth2 login.
+        *
+        * @param Request $request
+        * @return \Illuminate\Http\JsonResponse
+        */
+       public function handle(Request $request)//: \Illuminate\Http\JsonResponse
+       {
+               // Checking if the authorization code is present in the request.
+               if ($request->missing('code')) {
+                       if (env('APP_DEBUG')) {
+                               return response()->json([
+                                       'larascord_message' => config('larascord.error_messages.missing_code', 'The authorization code is missing.'),
+                                       'code' => 400
+                               ]);
+                       } else {
+                               return redirect('/')->with('error', config('larascord.error_messages.missing_code', 'The authorization code is missing.'));
+                       }
+               }
+
+               // Getting the access_token from the Discord API.
+               try {
+                       $accessToken = $this->getDiscordAccessToken($request->get('code'));
+               } catch (\Exception $e) {
+                       if (env('APP_DEBUG')) {
+                               return response()->json([
+                                       'larascord_message' => config('larascord.error_messages.invalid_code', 'The authorization code is invalid.'),
+                                       'message' => $e->getMessage(),
+                                       'code' => $e->getCode()
+                               ]);
+                       } else {
+                               return redirect('/')->with('error', config('larascord.error_messages.invalid_code', 'The authorization code is invalid.'));
+                       }
+               }
+
+               // Using the access_token to get the user's Discord ID.
+               try {
+                       $user = $this->getDiscordUser($accessToken->access_token);
+               } catch (\Exception $e) {
+                       if (env('APP_DEBUG')) {
+                               return response()->json([
+                                       'larascord_message' => config('larascord.error_messages.authorization_failed', 'The authorization failed.'),
+                                       'message' => $e->getMessage(),
+                                       'code' => $e->getCode()
+                               ]);
+                       } else {
+                               return redirect('/')->with('error', config('larascord.error_messages.authorization_failed', 'The authorization failed.'));
+                       }
+               }
+
+               // Making sure the current logged-in user's ID is matching the ID retrieved from the Discord API.
+               if (Auth::check() && (Auth::id() !== $user->id)) {
+                       Auth::logout();
+                       return redirect('/')->with('error', config('larascord.error_messages.invalid_user', 'The user ID doesn\'t match the logged-in user.'));
+               }
+
+               // Confirming the session in case the user was redirected from the password.confirm middleware.
+               if (Auth::check()) {
+                       $request->session()->put('auth.password_confirmed_at', time());
+               }
+
+               // Trying to create or update the user in the database.
+               try {
+                       $user = $this->createOrUpdateUser($user, $accessToken->refresh_token);
+               } catch (\Exception $e) {
+                       if (env('APP_DEBUG')) {
+                               return response()->json([
+                                       'larascord_message' => config('larascord.error_messages.database_error', 'There was an error while trying to create or update the user.'),
+                                       'message' => $e->getMessage(),
+                                       'code' => $e->getCode()
+                               ]);
+                       } else {
+                               return redirect('/')->with('error', config('larascord.error_messages.database_error', 'There was an error while trying to create or update the user.'));
+                       }
+               }
+
+               // Authenticating the user if the user is not logged in.
+               if (!Auth::check()) {
+                       Auth::login($user);
+               }
+
+               // Redirecting the user to the intended page or to the home page.
+               return redirect()->intended(RouteServiceProvider::HOME);
+       }
+
+       /**
+        * Handles the Discord OAuth2 callback.
+        *
+        * @param string $code
+        * @return object
+        * @throws \Illuminate\Http\Client\RequestException
+        */
+       private function getDiscordAccessToken(string $code): object
+       {
+               $this->tokenData['code'] = $code;
+
+               $response = Http::asForm()->post($this->tokenURL, $this->tokenData);
+
+               $response->throw();
+
+               return json_decode($response->body());
+       }
+
+       /**
+        * Handles the Discord OAuth2 login.
+        *
+        * @param string $access_token
+        * @return object
+        * @throws \Illuminate\Http\Client\RequestException
+        */
+       private function getDiscordUser(string $access_token): object
+       {
+               $response = Http::withToken($access_token)->get($this->apiURLBase);
+
+               $response->throw();
+
+               return json_decode($response->body());
+       }
+
+       /**
+        * Handles the creation or update of the user.
+        *
+        * @param object $user
+        * @param string $refresh_token
+        * @return User
+        * @throws \Exception
+        */
+       private function createOrUpdateUser(object $user, string $refresh_token): User
+       {
+               return User::updateOrCreate(
+                       [
+                               'id' => $user->id,
+                       ],
+                       [
+                               'username' => $user->username,
+                               'discriminator' => $user->discriminator,
+                               'email' => isset($user->email) ? $user->email : NULL,
+                               'avatar' => $user->avatar ?: NULL,
+                               'verified' => isset($user->verified) ? $user->verified : 0,
+                               'locale' => $user->locale,
+                               'mfa_enabled' => $user->mfa_enabled,
+                               'refresh_token' => $refresh_token
+                       ]
+               );
+       }
+
+}
index a6754ed6474da595069316247d38e5055fcdafbf..10e4d14857fd45000863df2fab536583a8404c6e 100644 (file)
@@ -2,9 +2,25 @@
 
 namespace App\Http\Controllers;
 
+use App\Models\Round;
+use App\Models\Tournament;
 use Illuminate\Http\Request;
 
 class RoundController extends Controller
 {
-       //
+
+       public function create(Request $request) {
+               $validatedData = $request->validate([
+                       'tournament_id' => 'required|exists:App\\Models\\Tournament,id',
+               ]);
+               $tournament = Tournament::findOrFail($validatedData['tournament_id']);
+               $this->authorize('addRound', $tournament);
+
+               $round = Round::create([
+                       'tournament_id' => $validatedData['tournament_id'],
+               ]);
+
+               return $round->toJson();
+       }
+
 }
index edf6d120f7403ad198c3332bf11db712d12be89c..c5bd40d84febb55ef3cb5fd3c632ee6c9f6e52b9 100644 (file)
@@ -2,9 +2,21 @@
 
 namespace App\Http\Controllers;
 
+use App\Models\Tournament;
 use Illuminate\Http\Request;
 
 class TournamentController extends Controller
 {
-       //
+
+       public function single(Request $request, $id) {
+               $tournament = Tournament::with(
+                       'rounds',
+                       'rounds.results',
+                       'participants',
+                       'participants.user',
+               )->findOrFail($id);
+               $this->authorize('view', $tournament);
+               return $tournament->toJson();
+       }
+
 }
index 942069255c74cff24f6fa55cd8d3ccd7f208056b..3b84c554e3e6320b2290c8b751930bef0d7ea69e 100644 (file)
@@ -17,4 +17,8 @@ class Round extends Model
                return $this->belongsTo(Tournament::class);
        }
 
+       protected $fillable = [
+               'tournament_id',
+       ];
+
 }
index dd129821890d28e9736b81bd6aaa800f35d15333..7f03b4acdf6a24ea4dc2bcec5145cb66a2b31ea9 100644 (file)
@@ -12,6 +12,15 @@ class User extends Authenticatable
 {
        use HasApiTokens, HasFactory, Notifiable;
 
+       public function isParticipant(Tournament $tournament) {
+               foreach ($tournament->participants as $participant) {
+                       if ($participant->user->id == $this->id) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
        /**
         * The attributes that are mass assignable.
         *
@@ -36,6 +45,8 @@ class User extends Authenticatable
         * @var array
         */
        protected $hidden = [
+               'email',
+               'mfa_enabled',
                'refresh_token',
                'remember_token',
        ];
index b79f8c17b11e2dac8cf359f4241f7fe75e22bc18..6352d714cba1fc7f174a8d56204ca64e6791e009 100644 (file)
@@ -91,4 +91,17 @@ class TournamentPolicy
        {
                return false;
        }
+
+       /**
+        * Determine whether the user can add rounds the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Tournament  $tournament
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function addRound(User $user, Tournament $tournament)
+       {
+               return $user->role === 'admin' || $user->isParticipant($tournament);
+       }
+
 }
index e3ee0afcfddc226eb38c66c98c4d913b33f6069e..ea2935f7420f36178b22b950c02747f22f59308c 100644 (file)
@@ -7,6 +7,7 @@
     "require": {
         "php": "^8.0.2",
         "beyondcode/laravel-websockets": "^1.13",
+        "doctrine/dbal": "^3.3",
         "guzzlehttp/guzzle": "^7.2",
         "jakyeru/larascord": "^3.0",
         "laravel/breeze": "^1.4",
index cc4656f6a2f9fd18a00f55f0a4f9f5e912e3d853..817950acd5fc77639ef76e668039977f7ee4f546 100644 (file)
@@ -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": "59127f8f165672d07ee6cc17271cbcea",
+    "content-hash": "18c06e191221e588fd030ccbe8b5e654",
     "packages": [
         {
             "name": "beyondcode/laravel-websockets",
             },
             "time": "2021-08-13T13:06:58+00:00"
         },
+        {
+            "name": "doctrine/cache",
+            "version": "2.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/cache.git",
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/cache/zipball/331b4d5dbaeab3827976273e9356b3b453c300ce",
+                "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce",
+                "shasum": ""
+            },
+            "require": {
+                "php": "~7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/common": ">2.2,<2.4"
+            },
+            "require-dev": {
+                "alcaeus/mongo-php-adapter": "^1.1",
+                "cache/integration-tests": "dev-master",
+                "doctrine/coding-standard": "^8.0",
+                "mongodb/mongodb": "^1.1",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "predis/predis": "~1.0",
+                "psr/cache": "^1.0 || ^2.0 || ^3.0",
+                "symfony/cache": "^4.4 || ^5.2 || ^6.0@dev",
+                "symfony/var-exporter": "^4.4 || ^5.2 || ^6.0@dev"
+            },
+            "suggest": {
+                "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Common\\Cache\\": "lib/Doctrine/Common/Cache"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                },
+                {
+                    "name": "Johannes Schmitt",
+                    "email": "schmittjoh@gmail.com"
+                }
+            ],
+            "description": "PHP Doctrine Cache library is a popular cache implementation that supports many different drivers such as redis, memcache, apc, mongodb and others.",
+            "homepage": "https://www.doctrine-project.org/projects/cache.html",
+            "keywords": [
+                "abstraction",
+                "apcu",
+                "cache",
+                "caching",
+                "couchdb",
+                "memcached",
+                "php",
+                "redis",
+                "xcache"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/cache/issues",
+                "source": "https://github.com/doctrine/cache/tree/2.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcache",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2021-07-17T14:49:29+00:00"
+        },
+        {
+            "name": "doctrine/dbal",
+            "version": "3.3.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/dbal.git",
+                "reference": "82331b861727c15b1f457ef05a8729e508e7ead5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/82331b861727c15b1f457ef05a8729e508e7ead5",
+                "reference": "82331b861727c15b1f457ef05a8729e508e7ead5",
+                "shasum": ""
+            },
+            "require": {
+                "composer-runtime-api": "^2",
+                "doctrine/cache": "^1.11|^2.0",
+                "doctrine/deprecations": "^0.5.3",
+                "doctrine/event-manager": "^1.0",
+                "php": "^7.3 || ^8.0",
+                "psr/cache": "^1|^2|^3",
+                "psr/log": "^1|^2|^3"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "9.0.0",
+                "jetbrains/phpstorm-stubs": "2021.1",
+                "phpstan/phpstan": "1.4.6",
+                "phpstan/phpstan-strict-rules": "^1.1",
+                "phpunit/phpunit": "9.5.16",
+                "psalm/plugin-phpunit": "0.16.1",
+                "squizlabs/php_codesniffer": "3.6.2",
+                "symfony/cache": "^5.2|^6.0",
+                "symfony/console": "^2.7|^3.0|^4.0|^5.0|^6.0",
+                "vimeo/psalm": "4.22.0"
+            },
+            "suggest": {
+                "symfony/console": "For helpful console commands such as SQL execution and import of files."
+            },
+            "bin": [
+                "bin/doctrine-dbal"
+            ],
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\DBAL\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                }
+            ],
+            "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
+            "homepage": "https://www.doctrine-project.org/projects/dbal.html",
+            "keywords": [
+                "abstraction",
+                "database",
+                "db2",
+                "dbal",
+                "mariadb",
+                "mssql",
+                "mysql",
+                "oci8",
+                "oracle",
+                "pdo",
+                "pgsql",
+                "postgresql",
+                "queryobject",
+                "sasql",
+                "sql",
+                "sqlite",
+                "sqlserver",
+                "sqlsrv"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/dbal/issues",
+                "source": "https://github.com/doctrine/dbal/tree/3.3.3"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2022-03-09T15:39:50+00:00"
+        },
+        {
+            "name": "doctrine/deprecations",
+            "version": "v0.5.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/deprecations.git",
+                "reference": "9504165960a1f83cc1480e2be1dd0a0478561314"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/deprecations/zipball/9504165960a1f83cc1480e2be1dd0a0478561314",
+                "reference": "9504165960a1f83cc1480e2be1dd0a0478561314",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1|^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^6.0|^7.0|^8.0",
+                "phpunit/phpunit": "^7.0|^8.0|^9.0",
+                "psr/log": "^1.0"
+            },
+            "suggest": {
+                "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+            "homepage": "https://www.doctrine-project.org/",
+            "support": {
+                "issues": "https://github.com/doctrine/deprecations/issues",
+                "source": "https://github.com/doctrine/deprecations/tree/v0.5.3"
+            },
+            "time": "2021-03-21T12:59:47+00:00"
+        },
+        {
+            "name": "doctrine/event-manager",
+            "version": "1.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/event-manager.git",
+                "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/event-manager/zipball/41370af6a30faa9dc0368c4a6814d596e81aba7f",
+                "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/common": "<2.9@dev"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^6.0",
+                "phpunit/phpunit": "^7.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Common\\": "lib/Doctrine/Common"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                },
+                {
+                    "name": "Johannes Schmitt",
+                    "email": "schmittjoh@gmail.com"
+                },
+                {
+                    "name": "Marco Pivetta",
+                    "email": "ocramius@gmail.com"
+                }
+            ],
+            "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
+            "homepage": "https://www.doctrine-project.org/projects/event-manager.html",
+            "keywords": [
+                "event",
+                "event dispatcher",
+                "event manager",
+                "event system",
+                "events"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/event-manager/issues",
+                "source": "https://github.com/doctrine/event-manager/tree/1.1.x"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-05-29T18:28:51+00:00"
+        },
         {
             "name": "doctrine/inflector",
             "version": "2.0.4",
             ],
             "time": "2021-12-04T23:24:31+00:00"
         },
+        {
+            "name": "psr/cache",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/cache.git",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for caching libraries",
+            "keywords": [
+                "cache",
+                "psr",
+                "psr-6"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/cache/tree/3.0.0"
+            },
+            "time": "2021-02-03T23:26:27+00:00"
+        },
         {
             "name": "psr/container",
             "version": "2.0.2",
index e5f9f096914e336767468499a6007d75bc0e9729..ee52e708bd0bad3d00bd8308e46297ee61ab0c43 100644 (file)
@@ -55,7 +55,7 @@ return [
     |
     */
 
-    'scopes' => env('LARASCORD_SCOPE', 'identify&email'),
+    'scopes' => env('LARASCORD_SCOPE', 'identify'),
 
     /*
     |--------------------------------------------------------------------------
diff --git a/database/migrations/2022_03_09_232126_optional_round_seed.php b/database/migrations/2022_03_09_232126_optional_round_seed.php
new file mode 100644 (file)
index 0000000..32a1a3e
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::table('rounds', function (Blueprint $table) {
+                       $table->string('seed')->default('')->change();
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               //
+       }
+};
diff --git a/database/migrations/2022_03_10_161017_optional_user_email.php b/database/migrations/2022_03_10_161017_optional_user_email.php
new file mode 100644 (file)
index 0000000..5f753ef
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::table('users', function (Blueprint $table) {
+                       $table->dropUnique('users_email_unique');
+                       $table->string('email')->nullable()->change();
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::table('users', function (Blueprint $table) {
+                       $table->string('email')->change()->unique();
+               });
+       }
+};
index 74cec3d7d9b7703f9a88e7663fe6564026710d71..b5bd9c9fdfd8388d500d97f5ae8e10b9e594719b 100644 (file)
@@ -1,9 +1,10 @@
 import axios from 'axios';
 import React, { useEffect, useState } from 'react';
-import { BrowserRouter, Route, Routes } from 'react-router-dom';
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
 
 import Header from './common/Header';
 import Front from './pages/Front';
+import Tournament from './pages/Tournament';
 import UserContext from '../helpers/UserContext';
 
 const App = () => {
@@ -41,7 +42,8 @@ const App = () => {
                        <Header doLogout={doLogout} />
                        {user ?
                                <Routes>
-                                       <Route path="*" element={<Front />} />
+                                       <Route path="tournaments/:id" element={<Tournament />} />
+                                       <Route path="*" element={<Navigate to="/tournaments/1" />} />
                                </Routes>
                        : <Front />}
                </UserContext.Provider>
diff --git a/resources/js/components/common/ErrorBoundary.js b/resources/js/components/common/ErrorBoundary.js
new file mode 100644 (file)
index 0000000..4fbc562
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+class ErrorBoundary extends React.Component {
+       constructor(props) {
+               super(props);
+               this.state = {
+                       error: null,
+               };
+       }
+
+       static getDerivedStateFromError(error) {
+               return { error };
+       }
+
+       componentDidCatch(error, errorInfo) {
+               console.log(error, errorInfo);
+       }
+
+       render() {
+               const { children } = this.props;
+               const { error } = this.state;
+               if (error) {
+                       return <p>error</p>;
+               }
+               return children;
+       }
+}
+
+ErrorBoundary.propTypes = {
+       children: PropTypes.node,
+};
+
+export default ErrorBoundary;
diff --git a/resources/js/components/common/ErrorMessage.js b/resources/js/components/common/ErrorMessage.js
new file mode 100644 (file)
index 0000000..8829de7
--- /dev/null
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+
+const ErrorMessage = ({ error }) => {
+       if (error.response) {
+               return <Alert variant="danger">
+                       <Alert.Heading>{i18n.t(`error.${error.response.status}.heading`)}</Alert.Heading>
+                       <p className="mb-0">{i18n.t(`error.${error.response.status}.description`)}</p>
+               </Alert>;
+       }
+       return <div className="error">Error</div>;
+};
+
+ErrorMessage.propTypes = {
+       error: PropTypes.shape({
+               message: PropTypes.string,
+               request: PropTypes.shape({}),
+               response: PropTypes.shape({
+                       status: PropTypes.number,
+               }),
+       }),
+};
+
+export default withTranslation()(ErrorMessage);
index d5b4f6cc3dd2cb6eeb0ebb379fb9c2f9e02058a3..6bab1a240fad9aa41008c21f0d215f26b70d208b 100644 (file)
@@ -5,11 +5,10 @@ import { LinkContainer } from 'react-router-bootstrap';
 import { withTranslation } from 'react-i18next';
 
 import Icon from './Icon';
+import { getAvatarUrl } from '../../helpers/User';
 import { withUser } from '../../helpers/UserContext';
 import i18n from '../../i18n';
 
-const getAvatarUrl = user => `//cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
-
 const Header = ({ doLogout, user }) =>
        <Navbar id="header" bg="dark" variant="dark">
                <Container fluid>
diff --git a/resources/js/components/common/Loading.js b/resources/js/components/common/Loading.js
new file mode 100644 (file)
index 0000000..b850dec
--- /dev/null
@@ -0,0 +1,8 @@
+import React from 'react';
+import { ProgressBar } from 'react-bootstrap';
+
+const Loading = () => <div className="loading">
+       <ProgressBar animated now={100} variant="info" />
+</div>;
+
+export default Loading;
diff --git a/resources/js/components/pages/NotFound.js b/resources/js/components/pages/NotFound.js
new file mode 100644 (file)
index 0000000..5e20596
--- /dev/null
@@ -0,0 +1,8 @@
+import React from 'react';
+
+const NotFound = () => <div>
+       <h1>Not Found</h1>
+       <p>Sorry</p>
+</div>;
+
+export default NotFound;
diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js
new file mode 100644 (file)
index 0000000..cdafd69
--- /dev/null
@@ -0,0 +1,56 @@
+import axios from 'axios';
+import React, { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+
+import ErrorBoundary from '../common/ErrorBoundary';
+import ErrorMessage from '../common/ErrorMessage';
+import Loading from '../common/Loading';
+import NotFound from '../pages/NotFound';
+import Detail from '../tournament/Detail';
+
+const Tournament = () => {
+       const params = useParams();
+       const { id } = params;
+
+       const [error, setError] = useState(null);
+       const [loading, setLoading] = useState(true);
+       const [tournament, setTournament] = useState(null);
+
+       useEffect(() => {
+               setLoading(true);
+               axios
+                       .get(`/api/tournaments/${id}`)
+                       .then(response => {
+                               setError(null);
+                               setLoading(false);
+                               setTournament(response.data);
+                       })
+                       .catch(error => {
+                               setError(error);
+                               setLoading(false);
+                               setTournament(null);
+                       });
+       }, [id]);
+
+       if (loading) {
+               return <Loading />;
+       }
+
+       if (error) {
+               return <ErrorMessage error={error} />;
+       }
+
+       if (!tournament) {
+               return <NotFound />;
+       }
+
+       const addRound = async () => {
+               await axios.post('/api/rounds', { tournament_id: tournament.id });
+       };
+
+       return <ErrorBoundary>
+               <Detail addRound={addRound} tournament={tournament} />
+       </ErrorBoundary>;
+};
+
+export default Tournament;
diff --git a/resources/js/components/participants/List.js b/resources/js/components/participants/List.js
new file mode 100644 (file)
index 0000000..e210f65
--- /dev/null
@@ -0,0 +1,33 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert, Col, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Box from '../users/Box';
+import i18n from '../../i18n';
+
+const List = ({ participants }) => participants && participants.length ?
+       <Row className="participants">
+               {participants.map(participant =>
+                       <Col md={4} lg={3} key={participant.id}>
+                               <Box user={participant.user} />
+                       </Col>
+               )}
+       </Row>
+:
+       <Alert variant="info">
+               {i18n.t('participants.empty')}
+       </Alert>
+;
+
+List.propTypes = {
+       participants: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+               user: PropTypes.shape({
+                       discriminator: PropTypes.string,
+                       username: PropTypes.string,
+               }),
+       })),
+};
+
+export default withTranslation()(List);
diff --git a/resources/js/components/results/Item.js b/resources/js/components/results/Item.js
new file mode 100644 (file)
index 0000000..993a382
--- /dev/null
@@ -0,0 +1,38 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { withTranslation } from 'react-i18next';
+
+import Box from '../users/Box';
+import { formatTime } from '../../helpers/Result';
+import { findResult } from '../../helpers/Participant';
+import i18n from '../../i18n';
+
+const Item = ({
+       participant,
+       round,
+}) => {
+       const result = findResult(participant, round);
+       return (
+               <div className="result">
+                       <Box user={participant.user} />
+                       {result ?
+                               <div>
+                                       {i18n.t('results.time', { time: formatTime(result) })}
+                               </div>
+                       : null}
+               </div>
+       );
+};
+
+Item.propTypes = {
+       participant: PropTypes.shape({
+               user: PropTypes.shape({
+               }),
+       }),
+       round: PropTypes.shape({
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(Item);
diff --git a/resources/js/components/results/List.js b/resources/js/components/results/List.js
new file mode 100644 (file)
index 0000000..11eb9a8
--- /dev/null
@@ -0,0 +1,26 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Item from './Item';
+
+const List = ({ round, tournament }) => <div className="results d-flex">
+       {tournament.participants.map(participant =>
+               <Item
+                       key={participant.id}
+                       participant={participant}
+                       round={round}
+                       tournament={tournament}
+               />
+       )}
+</div>;
+
+List.propTypes = {
+       round: PropTypes.shape({
+       }),
+       tournament: PropTypes.shape({
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+};
+
+export default List;
diff --git a/resources/js/components/rounds/Item.js b/resources/js/components/rounds/Item.js
new file mode 100644 (file)
index 0000000..064c7dc
--- /dev/null
@@ -0,0 +1,25 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { withTranslation } from 'react-i18next';
+
+import List from '../results/List';
+import i18n from '../../i18n';
+
+const Item = ({ round, tournament }) => <li className="round d-flex">
+       <div className="date">
+               {i18n.t('rounds.date', { date: new Date(round.created_at) })}
+       </div>
+       <List round={round} tournament={tournament} />
+</li>;
+
+Item.propTypes = {
+       round: PropTypes.shape({
+               created_at: PropTypes.string,
+       }),
+       tournament: PropTypes.shape({
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+};
+
+export default withTranslation()(Item);
diff --git a/resources/js/components/rounds/List.js b/resources/js/components/rounds/List.js
new file mode 100644 (file)
index 0000000..2ac8f17
--- /dev/null
@@ -0,0 +1,36 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Alert } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Item from './Item';
+import i18n from '../../i18n';
+
+const List = ({
+       rounds,
+       tournament,
+}) => rounds && rounds.length ?
+       <ol className="rounds">
+               {rounds.map(round =>
+                       <Item
+                               key={round.id}
+                               round={round}
+                               tournament={tournament}
+                       />
+               )}
+       </ol>
+:
+       <Alert variant="info">
+               {i18n.t('rounds.empty')}
+       </Alert>
+;
+
+List.propTypes = {
+       rounds: PropTypes.arrayOf(PropTypes.shape({
+               id: PropTypes.number,
+       })),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(List);
diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js
new file mode 100644 (file)
index 0000000..60c9d24
--- /dev/null
@@ -0,0 +1,52 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Container } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Participants from '../participants/List';
+import Rounds from '../rounds/List';
+import { mayAddRounds } from '../../helpers/permissions';
+import { withUser } from '../../helpers/UserContext';
+import i18n from '../../i18n';
+
+const Detail = ({
+       addRound,
+       tournament,
+       user,
+}) => <Container>
+       <div className="d-flex align-items-center justify-content-between">
+               <h1>{tournament.title}</h1>
+       </div>
+       <div className="d-flex align-items-center justify-content-between">
+               <h2>{i18n.t('participants.heading')}</h2>
+       </div>
+       {tournament.participants ?
+               <Participants participants={tournament.participants} tournament={tournament} />
+       : null}
+       <div className="d-flex align-items-center justify-content-between">
+               <h2>{i18n.t('rounds.heading')}</h2>
+               {addRound && mayAddRounds(user, tournament) ?
+                       <Button onClick={addRound}>
+                               {i18n.t('rounds.new')}
+                       </Button>
+               : null}
+       </div>
+       {tournament.rounds ?
+               <Rounds rounds={tournament.rounds} tournament={tournament} />
+       : null}
+</Container>;
+
+Detail.propTypes = {
+       addRound: PropTypes.func,
+       tournament: PropTypes.shape({
+               participants: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               rounds: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               title: PropTypes.string,
+       }),
+       user: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(withUser(Detail));
diff --git a/resources/js/components/users/Box.js b/resources/js/components/users/Box.js
new file mode 100644 (file)
index 0000000..139e6f6
--- /dev/null
@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { withTranslation } from 'react-i18next';
+
+import { getAvatarUrl } from '../../helpers/User';
+import i18n from '../../i18n';
+
+const Box = ({ user }) => user ?
+       <span className="user-box">
+               <img alt="" src={getAvatarUrl(user)} />
+               <span>{user.username}</span>
+               <span className="text-muted">
+                       {'#'}
+                       {user.discriminator}
+               </span>
+       </span>
+:
+       <span>{i18n.t('general.anonymous')}</span>
+;
+
+Box.propTypes = {
+       user: PropTypes.shape({
+               discriminator: PropTypes.string,
+               username: PropTypes.string,
+       }),
+};
+
+export default withTranslation()(Box);
diff --git a/resources/js/helpers/Participant.js b/resources/js/helpers/Participant.js
new file mode 100644 (file)
index 0000000..af6eecc
--- /dev/null
@@ -0,0 +1,9 @@
+export const findResult = (participant, round) => {
+       if (!participant || !participant.user_id) return null;
+       if (!round || !round.results || !round.results.length) return null;
+       return round.results.find(result => result.user_id === participant.user_id);
+};
+
+export default {
+       findResult,
+};
diff --git a/resources/js/helpers/Result.js b/resources/js/helpers/Result.js
new file mode 100644 (file)
index 0000000..633294d
--- /dev/null
@@ -0,0 +1,16 @@
+export const formatTime = result => {
+       const hours = `${Math.floor(result.time / 60 / 60)}`;
+       let minutes = `${Math.floor((result.time / 60) % 60)}`;
+       let seconds = `${Math.floor(result.time % 60)}`;
+       while (minutes.length < 2) {
+               minutes = `0${minutes}`;
+       }
+       while (seconds.length < 2) {
+               seconds = `0${seconds}`;
+       }
+       return `${hours}:${minutes}:${seconds}`;
+};
+
+export default {
+       formatTime,
+};
diff --git a/resources/js/helpers/User.js b/resources/js/helpers/User.js
new file mode 100644 (file)
index 0000000..02230ab
--- /dev/null
@@ -0,0 +1,5 @@
+export const getAvatarUrl = user => `//cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
+
+export default {
+       getAvatarUrl,
+};
diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js
new file mode 100644 (file)
index 0000000..ecc1e62
--- /dev/null
@@ -0,0 +1,15 @@
+/// NOTE: These permissions are for UI cosmetics only!
+/// They should be in sync with the backend Policies.
+
+export const isAdmin = user => user && user.role === 'admin';
+
+export const isSameUser = (user, subject) => user && subject && user.id === subject.id;
+
+// Tournaments
+
+export const isParticipant = (user, tournament) =>
+       user && tournament && tournament.participants &&
+       tournament.participants.find(p => p.user && p.user.id == user.id);
+
+export const mayAddRounds = (user, tournament) =>
+       isAdmin(user) || isParticipant(user, tournament);
index fcfbe4a667539c5f21affcd992cca9293aa738f4..e31b96c780800c8f34eb5c1c1139de0dcd6de73c 100644 (file)
@@ -12,5 +12,17 @@ export default {
                        DiscordIcon: 'Discord',
                        LogoutIcon: 'Logout',
                },
+               participants: {
+                       empty: 'Noch keine Teilnehmer eingetragen',
+                       heading: 'Teilnehmer',
+               },
+               results: {
+                       time: 'Zeit: {{ time }}',
+               },
+               rounds: {
+                       date: '{{ date, L }}',
+                       heading: 'Runden',
+                       new: 'Neue Runde',
+               },
        },
 };
index 13c7994deefc2ede472ead41d4fd618f3a3c05a1..ff99a53f6534807c665f7f05b9b8e4c4fca293f2 100644 (file)
@@ -12,3 +12,6 @@
 
 // Custom
 @import 'common';
+@import 'participants';
+@import 'rounds';
+@import 'users';
index 906d989c6f9fb2c598317f295d8cefd86caa70fb..8266db79bcd5641eefdc303bf7cc3127838d979a 100644 (file)
@@ -5,3 +5,8 @@
                margin: -0.5rem 0.25rem;
        }
 }
+
+h1 {
+       margin-top: 2.5rem;
+       margin-bottom: 2rem;
+}
diff --git a/resources/sass/participants.scss b/resources/sass/participants.scss
new file mode 100644 (file)
index 0000000..38d0148
--- /dev/null
@@ -0,0 +1,7 @@
+.participants {
+       margin: 1rem 0;
+       .user-box {
+               display: inline-block;
+               padding: 1ex;
+       }
+}
diff --git a/resources/sass/rounds.scss b/resources/sass/rounds.scss
new file mode 100644 (file)
index 0000000..dff973e
--- /dev/null
@@ -0,0 +1,8 @@
+.rounds {
+       .round {
+               margin: 1rem 0;
+               border: thin solid $secondary;
+               border-radius: 1ex;
+               padding: 1ex;
+       }
+}
diff --git a/resources/sass/users.scss b/resources/sass/users.scss
new file mode 100644 (file)
index 0000000..94aab1e
--- /dev/null
@@ -0,0 +1,8 @@
+.user-box {
+       img {
+               max-height: 2rem;
+               width: auto;
+               border-radius: 50%;
+               margin: 0 0.25rem;
+       }
+}
index eb6fa48c25d93f7bf753ba612cd2c7efecea5f4b..3ce6b1d15cd022d2eec6aef8a10ce80d0fb9f53e 100644 (file)
@@ -17,3 +17,7 @@ use Illuminate\Support\Facades\Route;
 Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
     return $request->user();
 });
+
+Route::post('rounds', 'App\Http\Controllers\RoundController@create');
+
+Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');
index ed62f1017910f25fb2adf2d6b66bfc9aea779bbe..903ca72c0694d6e4dfc3b3499978df73e99c5109 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 
+use App\Http\Controllers\DiscordController;
 use Illuminate\Support\Facades\Route;
 
 /*
@@ -14,3 +15,11 @@ use Illuminate\Support\Facades\Route;
 */
 
 Route::view('/{path?}', 'app')->where('path', '.*');
+
+Route::group(['prefix' => config('larascord.prefix'), 'middleware' => ['web']], function() {
+    Route::get('/callback', [DiscordController::class, 'handle'])
+        ->name('larascord.login');
+
+    Route::redirect('/refresh-token', '/login')
+        ->name('larascord.refresh_token');
+});