]> git.localhorst.tv Git - alttp.git/commitdiff
tournament admin control
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 13 Apr 2022 09:02:27 +0000 (11:02 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 13 Apr 2022 09:02:27 +0000 (11:02 +0200)
14 files changed:
app/Http/Controllers/TournamentController.php
app/Models/Protocol.php
app/Policies/TournamentPolicy.php
resources/js/components/common/Icon.js
resources/js/components/common/ToggleSwitch.js [new file with mode: 0644]
resources/js/components/protocol/Item.js
resources/js/components/tournament/Detail.js
resources/js/components/tournament/SettingsButton.js [new file with mode: 0644]
resources/js/components/tournament/SettingsDialog.js [new file with mode: 0644]
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/form.scss
routes/api.php

index 6ef003837c1f5b99c1d4bf25003bec42706847ea..fd9aab35d79e91bae3be78889d49885ea740df6c 100644 (file)
@@ -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();
+       }
+
 }
index 1214de6638962b8489cc537423b6f288528c4484..440aa0bec631e877901c80bff870abf921939ac7 100644 (file)
@@ -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 [
index a95dc95001a5c5fe1453cf8680fe9ecb4d8cbfb8..f58eee76dc12be424ddf5c26f89d49a57a80d676 100644 (file)
@@ -53,7 +53,7 @@ class TournamentPolicy
         */
        public function update(User $user, Tournament $tournament)
        {
-               return $user->isTournamentAdmin($tournament);
+               return $user->isAdmin() || $user->isTournamentAdmin($tournament);
        }
 
        /**
index c0c68176950b5c7d1a68f0a65c6a05f0b628ce88..f3e63e57ad3c3d7dddc369eba19c32c2d0e53d85 100644 (file)
@@ -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 (file)
index 0000000..da9653c
--- /dev/null
@@ -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 <div
+                       className={classNames.join(' ')}
+                       role="button"
+                       aria-pressed={value}
+                       tabIndex="0"
+                       onBlur={onBlur ? () => onBlur({ target: { name, value } }) : null}
+                       onClick={handleClick}
+                       onKeyDown={handleKey}
+               >
+                       <div className="handle">
+                               <span className="handle-label">
+                                       {value
+                                               ? onLabel || <Icon name="check" />
+                                               : offLabel || <Icon name="times" />
+                                       }
+                               </span>
+                       </div>
+               </div>;
+};
+
+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;
index 18b04cc7d6b96f5f5408f0af2d1e5cd6407038e2..3ccefbfe5d6f47ac5804708739f59b1f7c50b02c 100644 (file)
@@ -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 <Icon.ADD />;
                case 'round.lock':
+               case 'tournament.close':
                case 'tournament.lock':
                        return <Icon.LOCKED />;
                case 'round.unlock':
+               case 'tournament.open':
+               case 'tournament.unlock':
                        return <Icon.UNLOCKED />;
                default:
                        return <Icon.PROTOCOL />;
index d4e23cdba4e0ca359a634d2fda2b73e896e595c1..66b425e83740e4c0b81cdf10efa6cc2368016c04 100644 (file)
@@ -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 = ({
                                <div className="button-bar">
                                        <ApplicationsButton tournament={tournament} />
                                        <ApplyButton tournament={tournament} />
+                                       {mayUpdateTournament(user, tournament) ?
+                                               <SettingsButton tournament={tournament} />
+                                       : null}
                                        {mayViewProtocol(user, tournament) ?
                                                <Protocol id={tournament.id} />
                                        : null}
diff --git a/resources/js/components/tournament/SettingsButton.js b/resources/js/components/tournament/SettingsButton.js
new file mode 100644 (file)
index 0000000..2ff1abd
--- /dev/null
@@ -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 <>
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       title={i18n.t('button.settings')}
+                       variant="outline-secondary"
+               >
+                       <Icon.SETTINGS title="" />
+               </Button>
+               <SettingsDialog
+                       onHide={() => 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 (file)
index 0000000..9cecb14
--- /dev/null
@@ -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,
+}) =>
+<Modal className="settings-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('tournaments.settings')}
+               </Modal.Title>
+       </Modal.Header>
+       <Modal.Body>
+               <div className="d-flex align-items-center justify-content-between mb-3">
+                       <span>{i18n.t('tournaments.open')}</span>
+                       <ToggleSwitch
+                               onChange={({ target: { value } }) => value ? open(tournament) : close(tournament)}
+                               value={tournament.accept_applications}
+                       />
+               </div>
+               <div className="d-flex align-items-center justify-content-between">
+                       <span>{i18n.t('tournaments.locked')}</span>
+                       <ToggleSwitch
+                               onChange={({ target: { value } }) => value ? lock(tournament) : unlock(tournament)}
+                               value={tournament.locked}
+                       />
+               </div>
+       </Modal.Body>
+       <Modal.Footer>
+               <Button onClick={onHide} variant="secondary">
+                       {i18n.t('button.close')}
+               </Button>
+       </Modal.Footer>
+</Modal>;
+
+SettingsDialog.propTypes = {
+       onHide: PropTypes.func,
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+               accept_applications: PropTypes.bool,
+               locked: PropTypes.bool,
+       }),
+};
+
+export default withTranslation()(SettingsDialog);
index 18d03bffce1c6be3f56d04de783b42d734578588..4ce6935d83054ebb8bcb4e127166c53a0f181698 100644 (file)
@@ -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);
 
index 096a62977c016c3ddcf526ab766da181c7a322a3..7be92f30f48643a63eb25525eafce3fddf164223 100644 (file)
@@ -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',
index befe6a8fd65a14d22081873fb1811bbe1069a878..4001bbf4c82da5fbbf05ee14503fac27a72c537a 100644 (file)
@@ -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',
index ee2ee5fd567b03e2b3b7dbd8632916cdf23f8088..8ec46a19e675887bd9d5382b95beaa48c29aeed9 100644 (file)
@@ -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;
+               }
+       }
+}
index de8cd6c4836b27eea5be94c6568ee93724302dbc..43285317b68dfbfcba84a0dfa39e2c97451c7799 100644 (file)
@@ -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');