--- /dev/null
+<?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();
+ }
+
+}
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;
]);
$model->name = $guild->name;
$model->icon_hash = $guild->icon_hash;
+ $model->owner = $guild->owner_id;
$model->locale = $guild->preferred_locale;
$model->save();
}
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,
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,
--- /dev/null
+<?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;
+ }
+}
--- /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('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');
+ });
+ }
+};
--- /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('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');
+ });
+ }
+};
--- /dev/null
+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;
--- /dev/null
+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;
},
);
case 'tournament.close':
+ case 'tournament.discord':
case 'tournament.lock':
case 'tournament.open':
case 'tournament.unlock':
case 'tournament.open':
case 'tournament.unlock':
return <Icon.UNLOCKED />;
+ case 'tournament.discord':
+ return <Icon.DISCORD />;
default:
return <Icon.PROTOCOL />;
}
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';
}
};
+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,
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">
show: PropTypes.bool,
tournament: PropTypes.shape({
accept_applications: PropTypes.bool,
+ discord: PropTypes.string,
locked: PropTypes.bool,
}),
};
--- /dev/null
+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;
},
tournament: {
close: 'Anmeldung geschlossen',
+ discord: 'Discord Server verknüpft',
lock: 'Turnier gesperrt',
open: 'Anmeldung geöffnet',
unlock: 'Turnier entsperrt',
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',
},
tournament: {
close: 'Registration closed',
+ discord: 'Discord server connected',
lock: 'Tournament locked',
open: 'Registration opened',
unlock: 'Tournament unlocked',
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',
// Custom
@import 'common';
+@import 'discord';
@import 'form';
@import 'participants';
@import 'results';
--- /dev/null
+.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;
+ }
+}
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');
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');