From edd0e97bfdc544114f30bf4c13a929631c44a555 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Thu, 10 Mar 2022 23:53:35 +0100 Subject: [PATCH] basic result display --- app/Http/Controllers/DiscordController.php | 160 +++++++ app/Http/Controllers/RoundController.php | 18 +- app/Http/Controllers/TournamentController.php | 14 +- app/Models/Round.php | 4 + app/Models/User.php | 11 + app/Policies/TournamentPolicy.php | 13 + composer.json | 1 + composer.lock | 398 +++++++++++++++++- config/larascord.php | 2 +- .../2022_03_09_232126_optional_round_seed.php | 30 ++ .../2022_03_10_161017_optional_user_email.php | 33 ++ resources/js/components/App.js | 6 +- .../js/components/common/ErrorBoundary.js | 34 ++ .../js/components/common/ErrorMessage.js | 28 ++ resources/js/components/common/Header.js | 3 +- resources/js/components/common/Loading.js | 8 + resources/js/components/pages/NotFound.js | 8 + resources/js/components/pages/Tournament.js | 56 +++ resources/js/components/participants/List.js | 33 ++ resources/js/components/results/Item.js | 38 ++ resources/js/components/results/List.js | 26 ++ resources/js/components/rounds/Item.js | 25 ++ resources/js/components/rounds/List.js | 36 ++ resources/js/components/tournament/Detail.js | 52 +++ resources/js/components/users/Box.js | 28 ++ resources/js/helpers/Participant.js | 9 + resources/js/helpers/Result.js | 16 + resources/js/helpers/User.js | 5 + resources/js/helpers/permissions.js | 15 + resources/js/i18n/de.js | 12 + resources/sass/app.scss | 3 + resources/sass/common.scss | 5 + resources/sass/participants.scss | 7 + resources/sass/rounds.scss | 8 + resources/sass/users.scss | 8 + routes/api.php | 4 + routes/web.php | 9 + 37 files changed, 1158 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/DiscordController.php create mode 100644 database/migrations/2022_03_09_232126_optional_round_seed.php create mode 100644 database/migrations/2022_03_10_161017_optional_user_email.php create mode 100644 resources/js/components/common/ErrorBoundary.js create mode 100644 resources/js/components/common/ErrorMessage.js create mode 100644 resources/js/components/common/Loading.js create mode 100644 resources/js/components/pages/NotFound.js create mode 100644 resources/js/components/pages/Tournament.js create mode 100644 resources/js/components/participants/List.js create mode 100644 resources/js/components/results/Item.js create mode 100644 resources/js/components/results/List.js create mode 100644 resources/js/components/rounds/Item.js create mode 100644 resources/js/components/rounds/List.js create mode 100644 resources/js/components/tournament/Detail.js create mode 100644 resources/js/components/users/Box.js create mode 100644 resources/js/helpers/Participant.js create mode 100644 resources/js/helpers/Result.js create mode 100644 resources/js/helpers/User.js create mode 100644 resources/js/helpers/permissions.js create mode 100644 resources/sass/participants.scss create mode 100644 resources/sass/rounds.scss create mode 100644 resources/sass/users.scss diff --git a/app/Http/Controllers/DiscordController.php b/app/Http/Controllers/DiscordController.php new file mode 100644 index 0000000..6f9c03d --- /dev/null +++ b/app/Http/Controllers/DiscordController.php @@ -0,0 +1,160 @@ +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 + ] + ); + } + +} diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index a6754ed..10e4d14 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -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(); + } + } diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index edf6d12..c5bd40d 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -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(); + } + } diff --git a/app/Models/Round.php b/app/Models/Round.php index 9420692..3b84c55 100644 --- a/app/Models/Round.php +++ b/app/Models/Round.php @@ -17,4 +17,8 @@ class Round extends Model return $this->belongsTo(Tournament::class); } + protected $fillable = [ + 'tournament_id', + ]; + } diff --git a/app/Models/User.php b/app/Models/User.php index dd12982..7f03b4a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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', ]; diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index b79f8c1..6352d71 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -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); + } + } diff --git a/composer.json b/composer.json index e3ee0af..ea2935f 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index cc4656f..817950a 100644 --- a/composer.lock +++ b/composer.lock @@ -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", @@ -286,6 +286,353 @@ }, "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", @@ -2669,6 +3016,55 @@ ], "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", diff --git a/config/larascord.php b/config/larascord.php index e5f9f09..ee52e70 100644 --- a/config/larascord.php +++ b/config/larascord.php @@ -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 index 0000000..32a1a3e --- /dev/null +++ b/database/migrations/2022_03_09_232126_optional_round_seed.php @@ -0,0 +1,30 @@ +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 index 0000000..5f753ef --- /dev/null +++ b/database/migrations/2022_03_10_161017_optional_user_email.php @@ -0,0 +1,33 @@ +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(); + }); + } +}; diff --git a/resources/js/components/App.js b/resources/js/components/App.js index 74cec3d..b5bd9c9 100644 --- a/resources/js/components/App.js +++ b/resources/js/components/App.js @@ -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 = () => {
{user ? - } /> + } /> + } /> : } diff --git a/resources/js/components/common/ErrorBoundary.js b/resources/js/components/common/ErrorBoundary.js new file mode 100644 index 0000000..4fbc562 --- /dev/null +++ b/resources/js/components/common/ErrorBoundary.js @@ -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

error

; + } + 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 index 0000000..8829de7 --- /dev/null +++ b/resources/js/components/common/ErrorMessage.js @@ -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 + {i18n.t(`error.${error.response.status}.heading`)} +

{i18n.t(`error.${error.response.status}.description`)}

+
; + } + return
Error
; +}; + +ErrorMessage.propTypes = { + error: PropTypes.shape({ + message: PropTypes.string, + request: PropTypes.shape({}), + response: PropTypes.shape({ + status: PropTypes.number, + }), + }), +}; + +export default withTranslation()(ErrorMessage); diff --git a/resources/js/components/common/Header.js b/resources/js/components/common/Header.js index d5b4f6c..6bab1a2 100644 --- a/resources/js/components/common/Header.js +++ b/resources/js/components/common/Header.js @@ -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 }) => diff --git a/resources/js/components/common/Loading.js b/resources/js/components/common/Loading.js new file mode 100644 index 0000000..b850dec --- /dev/null +++ b/resources/js/components/common/Loading.js @@ -0,0 +1,8 @@ +import React from 'react'; +import { ProgressBar } from 'react-bootstrap'; + +const Loading = () =>
+ +
; + +export default Loading; diff --git a/resources/js/components/pages/NotFound.js b/resources/js/components/pages/NotFound.js new file mode 100644 index 0000000..5e20596 --- /dev/null +++ b/resources/js/components/pages/NotFound.js @@ -0,0 +1,8 @@ +import React from 'react'; + +const NotFound = () =>
+

Not Found

+

Sorry

+
; + +export default NotFound; diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js new file mode 100644 index 0000000..cdafd69 --- /dev/null +++ b/resources/js/components/pages/Tournament.js @@ -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 ; + } + + if (error) { + return ; + } + + if (!tournament) { + return ; + } + + const addRound = async () => { + await axios.post('/api/rounds', { tournament_id: tournament.id }); + }; + + return + + ; +}; + +export default Tournament; diff --git a/resources/js/components/participants/List.js b/resources/js/components/participants/List.js new file mode 100644 index 0000000..e210f65 --- /dev/null +++ b/resources/js/components/participants/List.js @@ -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 ? + + {participants.map(participant => + + + + )} + +: + + {i18n.t('participants.empty')} + +; + +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 index 0000000..993a382 --- /dev/null +++ b/resources/js/components/results/Item.js @@ -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 ( +
+ + {result ? +
+ {i18n.t('results.time', { time: formatTime(result) })} +
+ : null} +
+ ); +}; + +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 index 0000000..11eb9a8 --- /dev/null +++ b/resources/js/components/results/List.js @@ -0,0 +1,26 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import Item from './Item'; + +const List = ({ round, tournament }) =>
+ {tournament.participants.map(participant => + + )} +
; + +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 index 0000000..064c7dc --- /dev/null +++ b/resources/js/components/rounds/Item.js @@ -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 }) =>
  • +
    + {i18n.t('rounds.date', { date: new Date(round.created_at) })} +
    + +
  • ; + +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 index 0000000..2ac8f17 --- /dev/null +++ b/resources/js/components/rounds/List.js @@ -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 ? +
      + {rounds.map(round => + + )} +
    +: + + {i18n.t('rounds.empty')} + +; + +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 index 0000000..60c9d24 --- /dev/null +++ b/resources/js/components/tournament/Detail.js @@ -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, +}) => +
    +

    {tournament.title}

    +
    +
    +

    {i18n.t('participants.heading')}

    +
    + {tournament.participants ? + + : null} +
    +

    {i18n.t('rounds.heading')}

    + {addRound && mayAddRounds(user, tournament) ? + + : null} +
    + {tournament.rounds ? + + : null} +
    ; + +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 index 0000000..139e6f6 --- /dev/null +++ b/resources/js/components/users/Box.js @@ -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 ? + + + {user.username} + + {'#'} + {user.discriminator} + + +: + {i18n.t('general.anonymous')} +; + +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 index 0000000..af6eecc --- /dev/null +++ b/resources/js/helpers/Participant.js @@ -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 index 0000000..633294d --- /dev/null +++ b/resources/js/helpers/Result.js @@ -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 index 0000000..02230ab --- /dev/null +++ b/resources/js/helpers/User.js @@ -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 index 0000000..ecc1e62 --- /dev/null +++ b/resources/js/helpers/permissions.js @@ -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); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index fcfbe4a..e31b96c 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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', + }, }, }; diff --git a/resources/sass/app.scss b/resources/sass/app.scss index 13c7994..ff99a53 100644 --- a/resources/sass/app.scss +++ b/resources/sass/app.scss @@ -12,3 +12,6 @@ // Custom @import 'common'; +@import 'participants'; +@import 'rounds'; +@import 'users'; diff --git a/resources/sass/common.scss b/resources/sass/common.scss index 906d989..8266db7 100644 --- a/resources/sass/common.scss +++ b/resources/sass/common.scss @@ -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 index 0000000..38d0148 --- /dev/null +++ b/resources/sass/participants.scss @@ -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 index 0000000..dff973e --- /dev/null +++ b/resources/sass/rounds.scss @@ -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 index 0000000..94aab1e --- /dev/null +++ b/resources/sass/users.scss @@ -0,0 +1,8 @@ +.user-box { + img { + max-height: 2rem; + width: auto; + border-radius: 50%; + margin: 0 0.25rem; + } +} diff --git a/routes/api.php b/routes/api.php index eb6fa48..3ce6b1d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/routes/web.php b/routes/web.php index ed62f10..903ca72 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ 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'); +}); -- 2.39.2