]> git.localhorst.tv Git - alttp.git/commitdiff
tournament/guild connection
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 15 Apr 2022 18:49:05 +0000 (20:49 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 15 Apr 2022 18:49:05 +0000 (20:49 +0200)
17 files changed:
app/Http/Controllers/DiscordGuildController.php [new file with mode: 0644]
app/Http/Controllers/TournamentController.php
app/Models/DiscordGuild.php
app/Models/Protocol.php
app/Policies/DiscordGuildPolicy.php [new file with mode: 0644]
database/migrations/2022_04_15_123344_tournament_discord.php [new file with mode: 0644]
database/migrations/2022_04_15_143841_discord_guild_owner.php [new file with mode: 0644]
resources/js/components/common/DiscordSelect.js [new file with mode: 0644]
resources/js/components/discord-guilds/Box.js [new file with mode: 0644]
resources/js/components/protocol/Item.js
resources/js/components/tournament/SettingsDialog.js
resources/js/helpers/debounce.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/app.scss
resources/sass/discord.scss [new file with mode: 0644]
routes/api.php

diff --git a/app/Http/Controllers/DiscordGuildController.php b/app/Http/Controllers/DiscordGuildController.php
new file mode 100644 (file)
index 0000000..3037a92
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\DiscordGuild;
+use Illuminate\Http\Request;
+
+class DiscordGuildController extends Controller
+{
+
+       public function search(Request $request) {
+               $validatedData = $request->validate([
+                       'phrase' => 'string|nullable',
+               ]);
+
+               $guilds = DiscordGuild::query();
+               if (!$request->user()->can('viewAny', DiscordGuild::class)) {
+                       $guilds = $guilds->where('owner', '=', $request->user()->id);
+               }
+               if (!empty($validatedData['phrase'])) {
+                       $guilds = $guilds->where('name', 'LIKE', '%'.$validatedData['phrase'].'%');
+               }
+               $guilds = $guilds->limit(5);
+               return $guilds->get()->toJson();
+       }
+
+       public function single(Request $request, $guild_id) {
+               $guild = DiscordGuild::where('guild_id', '=', $guild_id)->firstOrFail();
+               $this->authorize('view', $guild);
+               return $guild->toJson();
+       }
+
+}
index fd9aab35d79e91bae3be78889d49885ea740df6c..7f6c913a2ba74b739f526acdb1f86dca96065262 100644 (file)
@@ -44,6 +44,22 @@ class TournamentController extends Controller
                return $tournament->toJson();
        }
 
+       public function discord(Request $request, Tournament $tournament) {
+               $this->authorize('update', $tournament);
+               $validatedData = $request->validate([
+                       'guild_id' => 'string|nullable',
+               ]);
+               if (array_key_exists('guild_id', $validatedData)) {
+                       $tournament->discord = $validatedData['guild_id'];
+               }
+               $tournament->save();
+               if ($tournament->wasChanged()) {
+                       TournamentChanged::dispatch($tournament);
+                       Protocol::tournamentDiscord($tournament, $request->user());
+               }
+               return $tournament->toJson();
+       }
+
        public function open(Request $request, Tournament $tournament) {
                $this->authorize('update', $tournament);
                $tournament->accept_applications = true;
index 3e91a4e461d7b17ff22dd1d713475ac1d94b6c2b..3accd02fa0db37e275f6096e117535c43983bf45 100644 (file)
@@ -17,6 +17,7 @@ class DiscordGuild extends Model
                ]);
                $model->name = $guild->name;
                $model->icon_hash = $guild->icon_hash;
+               $model->owner = $guild->owner_id;
                $model->locale = $guild->preferred_locale;
                $model->save();
        }
index 440aa0bec631e877901c80bff870abf921939ac7..67fb1cecbe0d95ada851375ae7a1598c8bb6967a 100644 (file)
@@ -156,6 +156,18 @@ class Protocol extends Model
                ProtocolAdded::dispatch($protocol);
        }
 
+       public static function tournamentDiscord(Tournament $tournament, User $user = null) {
+               $protocol = static::create([
+                       'tournament_id' => $tournament->id,
+                       'user_id' => $user ? $user->id : null,
+                       'type' => 'tournament.discord',
+                       'details' => [
+                               'tournament' => static::tournamentMemo($tournament),
+                       ],
+               ]);
+               ProtocolAdded::dispatch($protocol);
+       }
+
        public static function tournamentLocked(Tournament $tournament, User $user = null) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
@@ -168,7 +180,7 @@ class Protocol extends Model
                ProtocolAdded::dispatch($protocol);
        }
 
-       public static function tournamentOpenen(Tournament $tournament, User $user = null) {
+       public static function tournamentOpened(Tournament $tournament, User $user = null) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
                        'user_id' => $user ? $user->id : null,
diff --git a/app/Policies/DiscordGuildPolicy.php b/app/Policies/DiscordGuildPolicy.php
new file mode 100644 (file)
index 0000000..c94e67d
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\DiscordGuild;
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class DiscordGuildPolicy
+{
+       use HandlesAuthorization;
+
+       /**
+        * Determine whether the user can view any models.
+        *
+        * @param  \App\Models\User  $user
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function viewAny(User $user)
+       {
+               return $user->isAdmin();
+       }
+
+       /**
+        * Determine whether the user can view the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function view(User $user, DiscordGuild $discordGuild)
+       {
+               return true;
+       }
+
+       /**
+        * Determine whether the user can create models.
+        *
+        * @param  \App\Models\User  $user
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function create(User $user)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can update the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function update(User $user, DiscordGuild $discordGuild)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can delete the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function delete(User $user, DiscordGuild $discordGuild)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can restore the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function restore(User $user, DiscordGuild $discordGuild)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can permanently delete the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\DiscordGuild  $discordGuild
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function forceDelete(User $user, DiscordGuild $discordGuild)
+       {
+               return false;
+       }
+}
diff --git a/database/migrations/2022_04_15_123344_tournament_discord.php b/database/migrations/2022_04_15_123344_tournament_discord.php
new file mode 100644 (file)
index 0000000..338f918
--- /dev/null
@@ -0,0 +1,32 @@
+<?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('tournaments', function(Blueprint $table) {
+                       $table->string('discord')->nullable()->default(null);
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::table('tournaments', function(Blueprint $table) {
+                       $table->dropColumn('discord');
+               });
+       }
+};
diff --git a/database/migrations/2022_04_15_143841_discord_guild_owner.php b/database/migrations/2022_04_15_143841_discord_guild_owner.php
new file mode 100644 (file)
index 0000000..03980cd
--- /dev/null
@@ -0,0 +1,32 @@
+<?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('discord_guilds', function(Blueprint $table) {
+                       $table->string('owner')->nullable()->default(null);
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::table('discord_guilds', function(Blueprint $table) {
+                       $table->dropColumn('owner');
+               });
+       }
+};
diff --git a/resources/js/components/common/DiscordSelect.js b/resources/js/components/common/DiscordSelect.js
new file mode 100644 (file)
index 0000000..a279cde
--- /dev/null
@@ -0,0 +1,101 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Form, ListGroup } from 'react-bootstrap';
+
+import GuildBox from '../discord-guilds/Box';
+import debounce from '../../helpers/debounce';
+
+const DiscordSelect = ({ onChange, value }) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = useRef(null);
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('click', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('click', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       let ctrl = null;
+       const fetch = useCallback(debounce(async phrase => {
+               if (ctrl) {
+                       ctrl.abort();
+               }
+               ctrl = new AbortController();
+               try {
+                       const response = await axios.get(`/api/discord-guilds`, {
+                               params: {
+                                       phrase,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+       }, 300), []);
+
+       useEffect(() => {
+               fetch(search);
+       }, [search]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/discord-guilds/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div>{resolved ? <GuildBox guild={resolved} /> : value}</div>;
+       }
+       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+               <Form.Control
+                       className="search-input"
+                       name={Math.random().toString(20).substr(2, 10)}
+                       onChange={e => setSearch(e.target.value)}
+                       onFocus={() => setShowResults(true)}
+                       type="search"
+                       value={search}
+               />
+               <div className="search-results-holder">
+                       <ListGroup className="search-results">
+                               {results.map(result =>
+                                       <ListGroup.Item
+                                               action
+                                               key={result.id}
+                                               onClick={() => onChange({ target: { value: result.guild_id }})}
+                                       >
+                                               <GuildBox guild={result} />
+                                       </ListGroup.Item>
+                               )}
+                       </ListGroup>
+               </div>
+       </div>;
+};
+
+DiscordSelect.propTypes = {
+       onChange: PropTypes.func,
+       value: PropTypes.string,
+};
+
+export default DiscordSelect;
diff --git a/resources/js/components/discord-guilds/Box.js b/resources/js/components/discord-guilds/Box.js
new file mode 100644 (file)
index 0000000..dcfff9e
--- /dev/null
@@ -0,0 +1,20 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const getIconUrl = guild =>
+       `https://cdn.discordapp.com/icons/${guild.guild_id}/${guild.icon_hash}.png`;
+
+const Box = ({ guild }) => <div className="guild-box">
+               <img alt="" src={getIconUrl(guild)} />
+               <span>{guild.name}</span>
+       </div>;
+
+Box.propTypes = {
+       guild: PropTypes.shape({
+               guild_id: PropTypes.string,
+               icon_hash: PropTypes.string,
+               name: PropTypes.string,
+       }),
+};
+
+export default Box;
index 3ccefbfe5d6f47ac5804708739f59b1f7c50b02c..2ea8ea77ea774e7588651ca97fe2378d9ed713b1 100644 (file)
@@ -75,6 +75,7 @@ const getEntryDescription = entry => {
                                },
                        );
                case 'tournament.close':
+               case 'tournament.discord':
                case 'tournament.lock':
                case 'tournament.open':
                case 'tournament.unlock':
@@ -101,6 +102,8 @@ const getEntryIcon = entry => {
                case 'tournament.open':
                case 'tournament.unlock':
                        return <Icon.UNLOCKED />;
+               case 'tournament.discord':
+                       return <Icon.DISCORD />;
                default:
                        return <Icon.PROTOCOL />;
        }
index 9cecb14a70dcfcfcdcb2c62798457fc052288968..995c07b8250ee4388a267b26bac67c0b0880ad62 100644 (file)
@@ -5,6 +5,8 @@ import { Button, Modal } from 'react-bootstrap';
 import { withTranslation } from 'react-i18next';
 import toastr from 'toastr';
 
+import DiscordSelect from '../common/DiscordSelect';
+import Icon from '../common/Icon';
 import ToggleSwitch from '../common/ToggleSwitch';
 import i18n from '../../i18n';
 
@@ -44,6 +46,15 @@ const unlock = async tournament => {
        }
 };
 
+const setDiscord = async (tournament, guild_id) => {
+       try {
+               await axios.post(`/api/tournaments/${tournament.id}/discord`, { guild_id });
+               toastr.success(i18n.t('tournaments.discordSuccess'));
+       } catch (e) {
+               toastr.error(i18n.t('tournaments.discordError'));
+       }
+};
+
 const SettingsDialog = ({
        onHide,
        show,
@@ -63,13 +74,33 @@ const SettingsDialog = ({
                                value={tournament.accept_applications}
                        />
                </div>
-               <div className="d-flex align-items-center justify-content-between">
+               <div className="d-flex align-items-center justify-content-between mb-3">
                        <span>{i18n.t('tournaments.locked')}</span>
                        <ToggleSwitch
                                onChange={({ target: { value } }) => value ? lock(tournament) : unlock(tournament)}
                                value={tournament.locked}
                        />
                </div>
+               <div className="d-flex align-items-center justify-content-between">
+                       <div>
+                               <p>{i18n.t('tournaments.discord')}</p>
+                               <div>
+                                       <Button
+                                               href="https://discordapp.com/oauth2/authorize?client_id=951113702839549982&scope=bot"
+                                               target="_blank"
+                                               variant="discord"
+                                       >
+                                               <Icon.DISCORD />
+                                               {' '}
+                                               {i18n.t('tournaments.inviteBot')}
+                                       </Button>
+                               </div>
+                       </div>
+                       <DiscordSelect
+                               onChange={({ target: { value } }) => setDiscord(tournament, value)}
+                               value={tournament.discord}
+                       />
+               </div>
        </Modal.Body>
        <Modal.Footer>
                <Button onClick={onHide} variant="secondary">
@@ -83,6 +114,7 @@ SettingsDialog.propTypes = {
        show: PropTypes.bool,
        tournament: PropTypes.shape({
                accept_applications: PropTypes.bool,
+               discord: PropTypes.string,
                locked: PropTypes.bool,
        }),
 };
diff --git a/resources/js/helpers/debounce.js b/resources/js/helpers/debounce.js
new file mode 100644 (file)
index 0000000..cc92506
--- /dev/null
@@ -0,0 +1,22 @@
+const debounce = (func, wait, immediate) => {
+       let timeout = null;
+
+       return (...args) => {
+               const context = this;
+
+               const later = () => {
+                       timeout = null;
+                       if (!immediate) func.apply(context, args);
+               };
+
+               const callNow = immediate && !timeout;
+
+               clearTimeout(timeout);
+
+               timeout = setTimeout(later, wait);
+
+               if (callNow) func.apply(context, args);
+       };
+};
+
+export default debounce;
index 7be92f30f48643a63eb25525eafce3fddf164223..5bac532419b35fb05fb22a342ab4aa72596c63f0 100644 (file)
@@ -155,6 +155,7 @@ export default {
                                },
                                tournament: {
                                        close: 'Anmeldung geschlossen',
+                                       discord: 'Discord Server verknüpft',
                                        lock: 'Turnier gesperrt',
                                        open: 'Anmeldung geöffnet',
                                        unlock: 'Turnier entsperrt',
@@ -218,6 +219,10 @@ export default {
                        applySuccess: 'Anfrage gestellt',
                        closeError: 'Fehler beim Schließen der Anmledung',
                        closeSuccess: 'Anmeldung geschlossen',
+                       discord: 'Discord',
+                       discordError: 'Fehler beim Zuordnen',
+                       discordSuccess: 'Discord verknüpft',
+                       inviteBot: 'Bot einladen',
                        locked: 'Turnier sperren',
                        lockError: 'Fehler beim Sperren',
                        lockSuccess: 'Turnier gesperrt',
index 4001bbf4c82da5fbbf05ee14503fac27a72c537a..fb050d1695235ebe9b64741bafac99699b2c3f64 100644 (file)
@@ -155,6 +155,7 @@ export default {
                                },
                                tournament: {
                                        close: 'Registration closed',
+                                       discord: 'Discord server connected',
                                        lock: 'Tournament locked',
                                        open: 'Registration opened',
                                        unlock: 'Tournament unlocked',
@@ -218,6 +219,10 @@ export default {
                        applySuccess: 'Application sent',
                        closeError: 'Error closing registration',
                        closeSuccess: 'Registration closed',
+                       discord: 'Discord',
+                       discordError: 'Error connecting',
+                       discordSuccess: 'Discord associated',
+                       inviteBot: 'Invite bot',
                        locked: 'Lock rounds',
                        lockError: 'Error locking tournament',
                        lockSuccess: 'Tournament locked',
index 8cd0fb10448239334162b5fabbd6670c982f8ce3..2ba263594a979e5a56d0e778f89f887534c7a400 100644 (file)
@@ -12,6 +12,7 @@
 
 // Custom
 @import 'common';
+@import 'discord';
 @import 'form';
 @import 'participants';
 @import 'results';
diff --git a/resources/sass/discord.scss b/resources/sass/discord.scss
new file mode 100644 (file)
index 0000000..74875c0
--- /dev/null
@@ -0,0 +1,35 @@
+.discord-select {
+       .search-results-holder {
+               position: relative;
+       }
+       .search-results {
+               position: absolute;
+               left: 0;
+               top: 100%;
+               z-index: 1;
+               width: 100%;
+               border-top-left-radius: 0;
+               border-top-right-radius: 0;
+               box-shadow: 1ex 1ex 1ex rgba(0, 0, 0, 0.5);
+       }
+       &.collapsed .search-results {
+               display: none;
+       }
+       &.expanded .search-input {
+               border-bottom-left-radius: 0;
+               border-bottom-right-radius: 0;
+       }
+}
+
+.guild-box {
+       padding: 0;
+       color: inherit;
+       text-decoration: none;
+
+       img {
+               max-height: 2rem;
+               width: auto;
+               border-radius: 50%;
+               margin: 0 0.25rem;
+       }
+}
index 43285317b68dfbfcba84a0dfa39e2c97451c7799..502373393d4bcf24167ab1beb0ac7de1f17686c4 100644 (file)
@@ -21,6 +21,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
 Route::post('application/{application}/accept', 'App\Http\Controllers\ApplicationController@accept');
 Route::post('application/{application}/reject', 'App\Http\Controllers\ApplicationController@reject');
 
+Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search');
+Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildController@single');
+
 Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament');
 
 Route::post('results', 'App\Http\Controllers\ResultController@create');
@@ -33,6 +36,7 @@ Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unloc
 Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');
 Route::post('tournaments/{tournament}/apply', 'App\Http\Controllers\TournamentController@apply');
 Route::post('tournaments/{tournament}/close', 'App\Http\Controllers\TournamentController@close');
+Route::post('tournaments/{tournament}/discord', 'App\Http\Controllers\TournamentController@discord');
 Route::post('tournaments/{tournament}/lock', 'App\Http\Controllers\TournamentController@lock');
 Route::post('tournaments/{tournament}/open', 'App\Http\Controllers\TournamentController@open');
 Route::post('tournaments/{tournament}/unlock', 'App\Http\Controllers\TournamentController@unlock');