--- /dev/null
+<?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
+ ]
+ );
+ }
+
+}
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();
+ }
+
}
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();
+ }
+
}
return $this->belongsTo(Tournament::class);
}
+ protected $fillable = [
+ 'tournament_id',
+ ];
+
}
{
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.
*
* @var array
*/
protected $hidden = [
+ 'email',
+ 'mfa_enabled',
'refresh_token',
'remember_token',
];
{
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);
+ }
+
}
"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",
"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",
|
*/
- 'scopes' => env('LARASCORD_SCOPE', 'identify&email'),
+ 'scopes' => env('LARASCORD_SCOPE', 'identify'),
/*
|--------------------------------------------------------------------------
--- /dev/null
+<?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()
+ {
+ //
+ }
+};
--- /dev/null
+<?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();
+ });
+ }
+};
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 = () => {
<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>
--- /dev/null
+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;
--- /dev/null
+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);
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>
--- /dev/null
+import React from 'react';
+import { ProgressBar } from 'react-bootstrap';
+
+const Loading = () => <div className="loading">
+ <ProgressBar animated now={100} variant="info" />
+</div>;
+
+export default Loading;
--- /dev/null
+import React from 'react';
+
+const NotFound = () => <div>
+ <h1>Not Found</h1>
+ <p>Sorry</p>
+</div>;
+
+export default NotFound;
--- /dev/null
+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;
--- /dev/null
+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);
--- /dev/null
+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);
--- /dev/null
+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;
--- /dev/null
+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);
--- /dev/null
+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);
--- /dev/null
+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));
--- /dev/null
+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);
--- /dev/null
+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,
+};
--- /dev/null
+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,
+};
--- /dev/null
+export const getAvatarUrl = user => `//cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
+
+export default {
+ getAvatarUrl,
+};
--- /dev/null
+/// 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);
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',
+ },
},
};
// Custom
@import 'common';
+@import 'participants';
+@import 'rounds';
+@import 'users';
margin: -0.5rem 0.25rem;
}
}
+
+h1 {
+ margin-top: 2.5rem;
+ margin-bottom: 2rem;
+}
--- /dev/null
+.participants {
+ margin: 1rem 0;
+ .user-box {
+ display: inline-block;
+ padding: 1ex;
+ }
+}
--- /dev/null
+.rounds {
+ .round {
+ margin: 1rem 0;
+ border: thin solid $secondary;
+ border-radius: 1ex;
+ padding: 1ex;
+ }
+}
--- /dev/null
+.user-box {
+ img {
+ max-height: 2rem;
+ width: auto;
+ border-radius: 50%;
+ margin: 0 0.25rem;
+ }
+}
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');
<?php
+use App\Http\Controllers\DiscordController;
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');
+});