From 43da6b2ec78774e7b045a09c68af39717b5f5dbc Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Wed, 13 Apr 2022 11:02:27 +0200 Subject: [PATCH] tournament admin control --- app/Http/Controllers/TournamentController.php | 37 ++++++++ app/Models/Protocol.php | 36 ++++++++ app/Policies/TournamentPolicy.php | 2 +- resources/js/components/common/Icon.js | 1 + .../js/components/common/ToggleSwitch.js | 87 ++++++++++++++++++ resources/js/components/protocol/Item.js | 6 ++ resources/js/components/tournament/Detail.js | 5 ++ .../components/tournament/SettingsButton.js | 34 +++++++ .../components/tournament/SettingsDialog.js | 90 +++++++++++++++++++ resources/js/helpers/permissions.js | 3 + resources/js/i18n/de.js | 16 ++++ resources/js/i18n/en.js | 16 ++++ resources/sass/form.scss | 77 ++++++++++++++++ routes/api.php | 4 + 14 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/common/ToggleSwitch.js create mode 100644 resources/js/components/tournament/SettingsButton.js create mode 100644 resources/js/components/tournament/SettingsDialog.js diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index 6ef0038..fd9aab3 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Events\ApplicationAdded; +use App\Events\TournamentChanged; use App\Models\Application; use App\Models\Protocol; use App\Models\Tournament; @@ -43,4 +44,40 @@ class TournamentController extends Controller return $tournament->toJson(); } + public function open(Request $request, Tournament $tournament) { + $this->authorize('update', $tournament); + $tournament->accept_applications = true; + $tournament->save(); + TournamentChanged::dispatch($tournament); + Protocol::tournamentOpenen($tournament, $request->user()); + return $tournament->toJson(); + } + + public function close(Request $request, Tournament $tournament) { + $this->authorize('update', $tournament); + $tournament->accept_applications = false; + $tournament->save(); + TournamentChanged::dispatch($tournament); + Protocol::tournamentClosed($tournament, $request->user()); + return $tournament->toJson(); + } + + public function lock(Request $request, Tournament $tournament) { + $this->authorize('update', $tournament); + $tournament->locked = true; + $tournament->save(); + TournamentChanged::dispatch($tournament); + Protocol::tournamentLocked($tournament, $request->user()); + return $tournament->toJson(); + } + + public function unlock(Request $request, Tournament $tournament) { + $this->authorize('update', $tournament); + $tournament->locked = false; + $tournament->save(); + TournamentChanged::dispatch($tournament); + Protocol::tournamentUnlocked($tournament, $request->user()); + return $tournament->toJson(); + } + } diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 1214de6..440aa0b 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -132,6 +132,18 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function tournamentClosed(Tournament $tournament, User $user = null) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user ? $user->id : null, + 'type' => 'tournament.close', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function tournamentCreated(Tournament $tournament, User $user) { $protocol = static::create([ 'tournament_id' => $tournament->id, @@ -156,6 +168,30 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function tournamentOpenen(Tournament $tournament, User $user = null) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user ? $user->id : null, + 'type' => 'tournament.open', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + + public static function tournamentUnlocked(Tournament $tournament, User $user = null) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user ? $user->id : null, + 'type' => 'tournament.unlock', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + protected static function applicationMemo(Application $application) { return [ diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index a95dc95..f58eee7 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -53,7 +53,7 @@ class TournamentPolicy */ public function update(User $user, Tournament $tournament) { - return $user->isTournamentAdmin($tournament); + return $user->isAdmin() || $user->isTournamentAdmin($tournament); } /** diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index c0c6817..f3e63e5 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -75,6 +75,7 @@ Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt'); Icon.REJECT = makePreset('RejectIcon', 'square-xmark'); Icon.RESULT = makePreset('ResultIcon', 'clock'); Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal'); +Icon.SETTINGS = makePreset('SettingsIcon', 'cog'); Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']); Icon.THIRD_PLACE = makePreset('ThirdPlaceIcon', 'award'); Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open'); diff --git a/resources/js/components/common/ToggleSwitch.js b/resources/js/components/common/ToggleSwitch.js new file mode 100644 index 0000000..da9653c --- /dev/null +++ b/resources/js/components/common/ToggleSwitch.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import Icon from './Icon'; + +const ToggleSwitch = ({ + isInvalid, + isValid, + name, + offLabel, + onBlur, + onChange, + onLabel, + readonly, + value, +}) => { + const toggle = () => { + if (readonly) return; + if (onChange) onChange({ target: { name, value: !value } }); + }; + + const handleClick = event => { + event.stopPropagation(); + toggle(); + }; + + const handleKey = event => { + if ([13, 32].includes(event.which)) { + toggle(); + event.preventDefault(); + event.stopPropagation(); + } + }; + + const classNames = ['form-control', 'custom-toggle']; + if (value) classNames.push('is-toggled'); + if (isInvalid) classNames.push('is-invalid'); + if (isValid) classNames.push('is-valid'); + if (readonly) classNames.push('readonly'); + + return
onBlur({ target: { name, value } }) : null} + onClick={handleClick} + onKeyDown={handleKey} + > +
+ + {value + ? onLabel || + : offLabel || + } + +
+
; +}; + +ToggleSwitch.propTypes = { + id: PropTypes.string, + isInvalid: PropTypes.bool, + isValid: PropTypes.bool, + name: PropTypes.string, + offLabel: PropTypes.string, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onLabel: PropTypes.string, + readonly: PropTypes.bool, + value: PropTypes.bool, +}; + +ToggleSwitch.defaultProps = { + id: '', + isInvalid: false, + isValid: false, + name: '', + offLabel: '', + onBlur: null, + onChange: null, + onLabel: '', + readonly: false, + value: false, +}; + +export default ToggleSwitch; diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js index 18b04cc..3ccefbf 100644 --- a/resources/js/components/protocol/Item.js +++ b/resources/js/components/protocol/Item.js @@ -74,7 +74,10 @@ const getEntryDescription = entry => { number: getEntryRoundNumber(entry), }, ); + case 'tournament.close': case 'tournament.lock': + case 'tournament.open': + case 'tournament.unlock': return i18n.t( `protocol.description.${entry.type}`, entry, @@ -91,9 +94,12 @@ const getEntryIcon = entry => { case 'round.create': return ; case 'round.lock': + case 'tournament.close': case 'tournament.lock': return ; case 'round.unlock': + case 'tournament.open': + case 'tournament.unlock': return ; default: return ; diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js index d4e23cd..66b425e 100644 --- a/resources/js/components/tournament/Detail.js +++ b/resources/js/components/tournament/Detail.js @@ -6,6 +6,7 @@ import { withTranslation } from 'react-i18next'; import ApplyButton from './ApplyButton'; import Scoreboard from './Scoreboard'; import ScoreChartButton from './ScoreChartButton'; +import SettingsButton from './SettingsButton'; import ApplicationsButton from '../applications/Button'; import Protocol from '../protocol/Protocol'; import Rounds from '../rounds/List'; @@ -13,6 +14,7 @@ import Box from '../users/Box'; import { isRunner, mayAddRounds, + mayUpdateTournament, mayViewProtocol, } from '../../helpers/permissions'; import { @@ -50,6 +52,9 @@ const Detail = ({
+ {mayUpdateTournament(user, tournament) ? + + : null} {mayViewProtocol(user, tournament) ? : null} diff --git a/resources/js/components/tournament/SettingsButton.js b/resources/js/components/tournament/SettingsButton.js new file mode 100644 index 0000000..2ff1abd --- /dev/null +++ b/resources/js/components/tournament/SettingsButton.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import SettingsDialog from './SettingsDialog'; +import Icon from '../common/Icon'; +import i18n from '../../i18n'; + +const SettingsButton = ({ tournament }) => { + const [showDialog, setShowDialog] = useState(false); + + return <> + + setShowDialog(false)} + tournament={tournament} + show={showDialog} + /> + ; +}; + +SettingsButton.propTypes = { + tournament: PropTypes.shape({ + }), +}; + +export default withTranslation()(SettingsButton); diff --git a/resources/js/components/tournament/SettingsDialog.js b/resources/js/components/tournament/SettingsDialog.js new file mode 100644 index 0000000..9cecb14 --- /dev/null +++ b/resources/js/components/tournament/SettingsDialog.js @@ -0,0 +1,90 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import ToggleSwitch from '../common/ToggleSwitch'; +import i18n from '../../i18n'; + +const open = async tournament => { + try { + await axios.post(`/api/tournaments/${tournament.id}/open`); + toastr.success(i18n.t('tournaments.openSuccess')); + } catch (e) { + toastr.error(i18n.t('tournaments.openError')); + } +}; + +const close = async tournament => { + try { + await axios.post(`/api/tournaments/${tournament.id}/close`); + toastr.success(i18n.t('tournaments.closeSuccess')); + } catch (e) { + toastr.error(i18n.t('tournaments.closeError')); + } +}; + +const lock = async tournament => { + try { + await axios.post(`/api/tournaments/${tournament.id}/lock`); + toastr.success(i18n.t('tournaments.lockSuccess')); + } catch (e) { + toastr.error(i18n.t('tournaments.lockError')); + } +}; + +const unlock = async tournament => { + try { + await axios.post(`/api/tournaments/${tournament.id}/unlock`); + toastr.success(i18n.t('tournaments.unlockSuccess')); + } catch (e) { + toastr.error(i18n.t('tournaments.unlockError')); + } +}; + +const SettingsDialog = ({ + onHide, + show, + tournament, +}) => + + + + {i18n.t('tournaments.settings')} + + + +
+ {i18n.t('tournaments.open')} + value ? open(tournament) : close(tournament)} + value={tournament.accept_applications} + /> +
+
+ {i18n.t('tournaments.locked')} + value ? lock(tournament) : unlock(tournament)} + value={tournament.locked} + /> +
+
+ + + +
; + +SettingsDialog.propTypes = { + onHide: PropTypes.func, + show: PropTypes.bool, + tournament: PropTypes.shape({ + accept_applications: PropTypes.bool, + locked: PropTypes.bool, + }), +}; + +export default withTranslation()(SettingsDialog); diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 18d03bf..4ce6935 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -68,6 +68,9 @@ export const maySetSeed = (user, tournament, round) => !round.locked && (isRunner(user, tournament) || isTournamentAdmin(user, tournament)); +export const mayUpdateTournament = (user, tournament) => + isAdmin(user) || isTournamentAdmin(user, tournament); + export const mayViewProtocol = (user, tournament) => isTournamentCrew(user, tournament); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 096a629..7be92f3 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -23,6 +23,7 @@ export default { protocol: 'Protokoll', save: 'Speichern', search: 'Suche', + settings: 'Einstellungen', }, error: { 403: { @@ -59,6 +60,7 @@ export default { ProtocolIcon: 'Protokoll', ResultIcon: 'Ergebnis', SecondPlaceIcon: 'Zweiter Platz', + SettingsIcon: 'Einstellungen', StreamIcon: 'Stream', ThirdPlaceIcon: 'Dritter Platz', UnlockedIcon: 'Offen', @@ -152,7 +154,10 @@ export default { unlock: 'Runde #{{number}} entsperrt', }, tournament: { + close: 'Anmeldung geschlossen', lock: 'Turnier gesperrt', + open: 'Anmeldung geöffnet', + unlock: 'Turnier entsperrt', }, unknown: 'Unbekannter Protokolleintrag vom Typ {{type}}.', }, @@ -211,11 +216,22 @@ export default { apply: 'Beitreten', applyError: 'Fehler beim Abschicken der Anfrage', applySuccess: 'Anfrage gestellt', + closeError: 'Fehler beim Schließen der Anmledung', + closeSuccess: 'Anmeldung geschlossen', + locked: 'Turnier sperren', + lockError: 'Fehler beim Sperren', + lockSuccess: 'Turnier gesperrt', monitors: 'Monitore', noApplications: 'Derzeit keine Anmeldungen', noRecord: 'Turnier wird nicht gewertet', + open: 'Anmeldung geöffnet', + openError: 'Fehler beim Öffnen der Anmledung', + openSuccess: 'Anmeldung geöffnet', scoreboard: 'Scoreboard', scoreChart: 'Turnierverlauf', + settings: 'Einstellungen', + unlockError: 'Fehler beim Entsperren', + unlockSuccess: 'Turnier entsperrt', }, users: { discordTag: 'Discord Tag', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index befe6a8..4001bbf 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -23,6 +23,7 @@ export default { protocol: 'Protocol', save: 'Save', search: 'Search', + settings: 'Settings', }, error: { 403: { @@ -59,6 +60,7 @@ export default { ProtocolIcon: 'Protocol', ResultIcon: 'Result', SecondPlaceIcon: 'Second Place', + SettingsIcon: 'Settings', StreamIcon: 'Stream', ThirdPlaceIcon: 'Third Place', UnlockedIcon: 'Unlocked', @@ -152,7 +154,10 @@ export default { unlock: 'Round #{{number}} unlocked', }, tournament: { + close: 'Registration closed', lock: 'Tournament locked', + open: 'Registration opened', + unlock: 'Tournament unlocked', }, unknown: 'Unknown protocol entry of type {{type}}.', }, @@ -211,11 +216,22 @@ export default { apply: 'Apply', applyError: 'Error submitting application', applySuccess: 'Application sent', + closeError: 'Error closing registration', + closeSuccess: 'Registration closed', + locked: 'Lock rounds', + lockError: 'Error locking tournament', + lockSuccess: 'Tournament locked', monitors: 'Monitors', noApplications: 'No applications at this point', noRecord: 'Tournament set to not be recorded', + open: 'Open registration', + openError: 'Error opening registration', + openSuccess: 'Registration opened', scoreboard: 'Scoreboard', scoreChart: 'Score chart', + settings: 'Settings', + unlockError: 'Error unlocking tournaments', + unlockSuccess: 'Tournament unlocked', }, users: { discordTag: 'Discord tag', diff --git a/resources/sass/form.scss b/resources/sass/form.scss index ee2ee5f..8ec46a1 100644 --- a/resources/sass/form.scss +++ b/resources/sass/form.scss @@ -12,3 +12,80 @@ label { visibility: visible; } } + +.custom-toggle { + display: inline-block; + width: auto; + height: 2.25rem; + min-width: 58px; + background: #e9ecef; + border: 1px solid #ced4da; + border-radius: 1.25rem; + transition: background 200ms ease, padding 200ms ease; + text-align: left; + padding: 3px 3px 3px 24px; + + &.is-toggled { + background: #4f94d9; + padding: 3px 24px 3px 3px; + + .handle-label { + color: #4f94d9; + } + } + + &.is-invalid, + &.is-valid { + background-image: none; + padding: 3px 24px 3px 3px; + &.is-toggled { + padding: 3px 24px 3px 3px; + } + } + &.is-invalid.is-toggled { + background: red; + + .handle-label { + color: red; + } + } + + &:hover { + cursor: pointer; + } + + &:focus { + background-color: #e9ecef; + border-color: #ced4da; + &.is-toggled { + background-color: #4f94d9; + } + } + + .handle { + display: inline-block; + min-width: 40px; + height: 28px; + background: #fff; + border-radius: 1rem; + text-transform: uppercase; + color: green; + text-align: center; + padding: 0 10px; + + .handle-label { + display: inline-block; + margin-top: 2px; + font-size: 18px; + font-weight: 600; + white-space: nowrap; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + color: #495057; + } + } +} diff --git a/routes/api.php b/routes/api.php index de8cd6c..4328531 100644 --- a/routes/api.php +++ b/routes/api.php @@ -32,6 +32,10 @@ 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}/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'); Route::get('users/{id}', 'App\Http\Controllers\UserController@single'); Route::post('users/set-language', 'App\Http\Controllers\UserController@setLanguage'); -- 2.39.2