From b940ecdcd62df84881f4d4555801672155c9dcce Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 15 Apr 2022 20:49:05 +0200 Subject: [PATCH] tournament/guild connection --- .../Controllers/DiscordGuildController.php | 33 ++++++ app/Http/Controllers/TournamentController.php | 16 +++ app/Models/DiscordGuild.php | 1 + app/Models/Protocol.php | 14 ++- app/Policies/DiscordGuildPolicy.php | 94 ++++++++++++++++ .../2022_04_15_123344_tournament_discord.php | 32 ++++++ .../2022_04_15_143841_discord_guild_owner.php | 32 ++++++ .../js/components/common/DiscordSelect.js | 101 ++++++++++++++++++ resources/js/components/discord-guilds/Box.js | 20 ++++ resources/js/components/protocol/Item.js | 3 + .../components/tournament/SettingsDialog.js | 34 +++++- resources/js/helpers/debounce.js | 22 ++++ resources/js/i18n/de.js | 5 + resources/js/i18n/en.js | 5 + resources/sass/app.scss | 1 + resources/sass/discord.scss | 35 ++++++ routes/api.php | 4 + 17 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/DiscordGuildController.php create mode 100644 app/Policies/DiscordGuildPolicy.php create mode 100644 database/migrations/2022_04_15_123344_tournament_discord.php create mode 100644 database/migrations/2022_04_15_143841_discord_guild_owner.php create mode 100644 resources/js/components/common/DiscordSelect.js create mode 100644 resources/js/components/discord-guilds/Box.js create mode 100644 resources/js/helpers/debounce.js create mode 100644 resources/sass/discord.scss diff --git a/app/Http/Controllers/DiscordGuildController.php b/app/Http/Controllers/DiscordGuildController.php new file mode 100644 index 0000000..3037a92 --- /dev/null +++ b/app/Http/Controllers/DiscordGuildController.php @@ -0,0 +1,33 @@ +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(); + } + +} diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index fd9aab3..7f6c913 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -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; diff --git a/app/Models/DiscordGuild.php b/app/Models/DiscordGuild.php index 3e91a4e..3accd02 100644 --- a/app/Models/DiscordGuild.php +++ b/app/Models/DiscordGuild.php @@ -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(); } diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 440aa0b..67fb1ce 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -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 index 0000000..c94e67d --- /dev/null +++ b/app/Policies/DiscordGuildPolicy.php @@ -0,0 +1,94 @@ +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 index 0000000..338f918 --- /dev/null +++ b/database/migrations/2022_04_15_123344_tournament_discord.php @@ -0,0 +1,32 @@ +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 index 0000000..03980cd --- /dev/null +++ b/database/migrations/2022_04_15_143841_discord_guild_owner.php @@ -0,0 +1,32 @@ +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 index 0000000..a279cde --- /dev/null +++ b/resources/js/components/common/DiscordSelect.js @@ -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
{resolved ? : value}
; + } + return
+ setSearch(e.target.value)} + onFocus={() => setShowResults(true)} + type="search" + value={search} + /> +
+ + {results.map(result => + onChange({ target: { value: result.guild_id }})} + > + + + )} + +
+
; +}; + +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 index 0000000..dcfff9e --- /dev/null +++ b/resources/js/components/discord-guilds/Box.js @@ -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 }) =>
+ + {guild.name} +
; + +Box.propTypes = { + guild: PropTypes.shape({ + guild_id: PropTypes.string, + icon_hash: PropTypes.string, + name: PropTypes.string, + }), +}; + +export default Box; diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js index 3ccefbf..2ea8ea7 100644 --- a/resources/js/components/protocol/Item.js +++ b/resources/js/components/protocol/Item.js @@ -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 ; + case 'tournament.discord': + return ; default: return ; } diff --git a/resources/js/components/tournament/SettingsDialog.js b/resources/js/components/tournament/SettingsDialog.js index 9cecb14..995c07b 100644 --- a/resources/js/components/tournament/SettingsDialog.js +++ b/resources/js/components/tournament/SettingsDialog.js @@ -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} /> -
+
{i18n.t('tournaments.locked')} value ? lock(tournament) : unlock(tournament)} value={tournament.locked} />
+
+
+

{i18n.t('tournaments.discord')}

+
+ +
+
+ setDiscord(tournament, value)} + value={tournament.discord} + /> +