From: Daniel Karbach Date: Sun, 10 Apr 2022 22:54:55 +0000 (+0200) Subject: application admin UI X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;ds=sidebyside;h=cd36cb0ba2718e6bfa08765e7702d57dfe7fd733;p=alttp.git application admin UI --- diff --git a/app/Events/ApplicationChanged.php b/app/Events/ApplicationChanged.php new file mode 100644 index 0000000..eb37074 --- /dev/null +++ b/app/Events/ApplicationChanged.php @@ -0,0 +1,40 @@ +application = $application; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new Channel('Tournament.'.$this->application->tournament_id); + } + + public $application; + +} diff --git a/app/Events/ApplicationRemoved.php b/app/Events/ApplicationRemoved.php new file mode 100644 index 0000000..26ba162 --- /dev/null +++ b/app/Events/ApplicationRemoved.php @@ -0,0 +1,41 @@ +application_id = $application_id; + $this->tournament_id = $tournament_id; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return new Channel('Tournament.'.$this->tournament_id); + } + + public $application_id; + public $tournament_id; + +} diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php new file mode 100644 index 0000000..b0ab063 --- /dev/null +++ b/app/Http/Controllers/ApplicationController.php @@ -0,0 +1,44 @@ +authorize('accept', $application); + + $participant = Participant::firstOrCreate([ + 'tournament_id' => $application->tournament_id, + 'user_id' => $application->user_id, + ]); + $participant->makeRunner(); + + $application->delete(); + ApplicationRemoved::dispatch($application->id, $application->tournament_id); + + Protocol::applicationAccepted($application->tournament, $application, $request->user()); + + return $participant->toJson(); + } + + public function reject(Request $request, Application $application) { + $this->authorize('reject', $application); + + $application->denied = true; + $application->save(); + ApplicationChanged::dispatch($application); + + Protocol::applicationRejected($application->tournament, $application, $request->user()); + + return $application->toJson(); + } + +} diff --git a/app/Http/Controllers/TournamentController.php b/app/Http/Controllers/TournamentController.php index 9d62c23..6ef0038 100644 --- a/app/Http/Controllers/TournamentController.php +++ b/app/Http/Controllers/TournamentController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Events\ApplicationAdded; use App\Models\Application; +use App\Models\Protocol; use App\Models\Tournament; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; @@ -18,6 +19,7 @@ class TournamentController extends Controller $application->user_id = $request->user()->id; $application->save(); ApplicationAdded::dispatch($application); + Protocol::applicationReceived($tournament, $application, $request->user()); return $tournament->toJson(); } diff --git a/app/Models/Participant.php b/app/Models/Participant.php index 91b946d..38ecfda 100644 --- a/app/Models/Participant.php +++ b/app/Models/Participant.php @@ -82,6 +82,18 @@ class Participant extends Model return in_array('admin', $this->roles); } + public function makeRunner() { + if (!is_array($this->roles)) { + $this->roles = ['runner']; + } else if (!in_array('runner', $this->roles)) { + $newRoles = array_values($this->roles); + $newRoles[] = 'runner'; + $this->roles = $newRoles; + } + $this->save(); + ParticipantChanged::dispatch($this); + } + public function tournament() { return $this->belongsTo(Tournament::class); @@ -96,6 +108,11 @@ class Participant extends Model 'roles' => 'array', ]; + protected $fillable = [ + 'tournament_id', + 'user_id', + ]; + protected $with = [ 'user', ]; diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 995f9c0..7a90851 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -10,6 +10,48 @@ class Protocol extends Model { use HasFactory; + public static function applicationAccepted(Tournament $tournament, Application $application, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'application.accepted', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'application' => static::applicationMemo($application), + 'user' => static::userMemo($application->user), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + + public static function applicationReceived(Tournament $tournament, Application $application, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'application.received', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'application' => static::applicationMemo($application), + 'user' => static::userMemo($application->user), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + + public static function applicationRejected(Tournament $tournament, Application $application, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'application.rejected', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'application' => static::applicationMemo($application), + 'user' => static::userMemo($application->user), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function resultCommented(Tournament $tournament, Result $result, User $user) { $protocol = static::create([ 'tournament_id' => $tournament->id, @@ -115,6 +157,13 @@ class Protocol extends Model } + protected static function applicationMemo(Application $application) { + return [ + 'id' => $application->id, + 'denied' => $application->denied, + ]; + } + protected static function resultMemo(Result $result) { return [ 'id' => $result->id, @@ -137,6 +186,7 @@ class Protocol extends Model protected static function tournamentMemo(Tournament $tournament) { return [ 'id' => $tournament->id, + 'accept_applications' => $tournament->accept_applications, 'locked' => $tournament->locked, 'no_record' => $tournament->no_record, 'title' => $tournament->title, @@ -149,6 +199,7 @@ class Protocol extends Model 'username' => $user->username, 'discriminator' => $user->discriminator, 'avatar' => $user->avatar, + 'nickname' => $user->nickname, ]; } diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 3dc9ad8..a816051 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -94,4 +94,30 @@ class ApplicationPolicy return false; } + /** + * Determine whether the user can accept the application. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function accept(User $user, Application $application) + { + return $user->isAdmin() + || $user->isTournamentAdmin($application->tournament); + } + + /** + * Determine whether the user can accept the application. + * + * @param \App\Models\User $user + * @param \App\Models\Application $application + * @return \Illuminate\Auth\Access\Response|bool + */ + public function reject(User $user, Application $application) + { + return $user->isAdmin() + || $user->isTournamentAdmin($application->tournament); + } + } diff --git a/resources/js/components/applications/Button.js b/resources/js/components/applications/Button.js new file mode 100644 index 0000000..415042b --- /dev/null +++ b/resources/js/components/applications/Button.js @@ -0,0 +1,53 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { Badge, Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import Dialog from './Dialog'; +import Icon from '../common/Icon'; +import { mayHandleApplications } from '../../helpers/permissions'; +import { getPendingApplications } from '../../helpers/Tournament'; +import { withUser } from '../../helpers/UserContext'; +import i18n from '../../i18n'; + +const ApplicationsButton = ({ tournament, user }) => { + const [showDialog, setShowDialog] = useState(false); + + if (!user || !tournament.accept_applications || !mayHandleApplications(user, tournament)) { + return null; + } + + const pending = getPendingApplications(tournament); + + return <> + + setShowDialog(false)} + show={showDialog} + tournament={tournament} + /> + ; +}; + +ApplicationsButton.propTypes = { + tournament: PropTypes.shape({ + accept_applications: PropTypes.bool, + id: PropTypes.number, + }), + user: PropTypes.shape({ + }), +}; + +export default withTranslation()(withUser(ApplicationsButton)); diff --git a/resources/js/components/applications/Dialog.js b/resources/js/components/applications/Dialog.js new file mode 100644 index 0000000..a42fb3d --- /dev/null +++ b/resources/js/components/applications/Dialog.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Alert, Button, Modal } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import List from './List'; +import i18n from '../../i18n'; + +const Dialog = ({ onHide, show, tournament }) => + + + + {i18n.t('tournaments.applications')} + + + + {tournament.applications && tournament.applications.length ? + + : + + {i18n.t('tournaments.noApplications')} + + } + + + + +; + +Dialog.propTypes = { + onHide: PropTypes.func, + show: PropTypes.bool, + tournament: PropTypes.shape({ + applications: PropTypes.arrayOf(PropTypes.shape({ + })) + }), +}; + +export default withTranslation()(Dialog); diff --git a/resources/js/components/applications/Item.js b/resources/js/components/applications/Item.js new file mode 100644 index 0000000..8f6aef1 --- /dev/null +++ b/resources/js/components/applications/Item.js @@ -0,0 +1,60 @@ +import axios from 'axios'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, ListGroup } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import Icon from '../common/Icon'; +import Box from '../users/Box'; +import i18n from '../../i18n'; + +const accept = async (tournament, application) => { + try { + await axios.post(`/api/application/${application.id}/accept`); + toastr.success(i18n.t('applications.acceptSuccess')); + } catch (e) { + toastr.error(i18n.t('applications.acceptError')); + } +}; + +const reject = async (tournament, application) => { + try { + await axios.post(`/api/application/${application.id}/reject`); + toastr.success(i18n.t('applications.rejectSuccess')); + } catch (e) { + toastr.error(i18n.t('applications.rejectError')); + } +}; + +const Item = ({ application, tournament }) => + + +
+ + +
+
; + +Item.propTypes = { + application: PropTypes.shape({ + user: PropTypes.shape({ + }), + }), + tournament: PropTypes.shape({ + }), +}; + +export default withTranslation()(Item); diff --git a/resources/js/components/applications/List.js b/resources/js/components/applications/List.js new file mode 100644 index 0000000..6460be3 --- /dev/null +++ b/resources/js/components/applications/List.js @@ -0,0 +1,21 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { ListGroup } from 'react-bootstrap'; + +import Item from './Item'; + +const List = ({ tournament }) => + + {tournament.applications.map(application => + + )} +; + +List.propTypes = { + tournament: PropTypes.shape({ + applications: PropTypes.arrayOf(PropTypes.shape({ + })), + }), +}; + +export default List; diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 607deb9..6d05090 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -57,8 +57,10 @@ const makePreset = (presetDisplayName, presetName) => { return withTranslation()(preset); }; +Icon.ACCEPT = makePreset('AcceptIcon', 'square-check'); Icon.ADD = makePreset('AddIcon', 'circle-plus'); Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket'); +Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running'); Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']); Icon.EDIT = makePreset('EditIcon', 'edit'); Icon.FINISHED = makePreset('FinishedIcon', 'square-check'); @@ -69,6 +71,7 @@ Icon.LOCKED = makePreset('LockedIcon', 'lock'); Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt'); Icon.PENDING = makePreset('PendingIcon', 'clock'); Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt'); +Icon.REJECT = makePreset('RejectIcon', 'square-xmark'); Icon.RESULT = makePreset('ResultIcon', 'clock'); Icon.SECOND_PLACE = makePreset('SecondPlaceIcon', 'medal'); Icon.STREAM = makePreset('StreamIcon', ['fab', 'twitch']); diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js index 729e55d..00a1934 100644 --- a/resources/js/components/pages/Tournament.js +++ b/resources/js/components/pages/Tournament.js @@ -13,6 +13,7 @@ import { patchResult, patchRound, patchUser, + removeApplication, sortParticipants, } from '../../helpers/Tournament'; @@ -53,7 +54,13 @@ const Tournament = () => { setTournament(tournament => patchApplication(tournament, e.application)); } }) + .listen('ApplicationRemoved', e => { + if (e.application_id) { + setTournament(tournament => removeApplication(tournament, e.application_id)); + } + }) .listen('ParticipantChanged', e => { + console.log(e); if (e.participant) { setTournament(tournament => patchParticipant(tournament, e.participant)); } diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js index 74bfa50..9fcc7e5 100644 --- a/resources/js/components/protocol/Item.js +++ b/resources/js/components/protocol/Item.js @@ -7,6 +7,7 @@ import { Trans, withTranslation } from 'react-i18next'; import Icon from '../common/Icon'; import Spoiler from '../common/Spoiler'; import { formatTime } from '../../helpers/Result'; +import { getUserName } from '../../helpers/User'; import i18n from '../../i18n'; const getEntryDate = entry => { @@ -16,6 +17,11 @@ const getEntryDate = entry => { : dateStr; }; +const getEntryDetailsUsername = entry => { + if (!entry || !entry.details || !entry.details.user) return 'Anonymous'; + return getUserName(entry.details.user); +}; + const getEntryRoundNumber = entry => (entry && entry.details && entry.details.round && entry.details.round.number) || '?'; @@ -28,6 +34,16 @@ const getEntryResultTime = entry => { const getEntryDescription = entry => { switch (entry.type) { + case 'application.accepted': + case 'application.received': + case 'application.rejected': + return i18n.t( + `protocol.description.${entry.type}`, + { + ...entry, + username: getEntryDetailsUsername(entry), + }, + ); case 'result.report': { const time = getEntryResultTime(entry); return diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js index b20f208..cdc9b60 100644 --- a/resources/js/components/tournament/Detail.js +++ b/resources/js/components/tournament/Detail.js @@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next'; import ApplyButton from './ApplyButton'; import Scoreboard from './Scoreboard'; +import ApplicationsButton from '../applications/Button'; import Protocol from '../protocol/Protocol'; import Rounds from '../rounds/List'; import Box from '../users/Box'; @@ -46,6 +47,7 @@ const Detail = ({

{tournament.title}

+ {mayViewProtocol(user, tournament) ? diff --git a/resources/js/helpers/Application.js b/resources/js/helpers/Application.js new file mode 100644 index 0000000..ee0b96d --- /dev/null +++ b/resources/js/helpers/Application.js @@ -0,0 +1,17 @@ +import User from './User'; + +export const compareUsername = (a, b) => { + const a_name = a && a.user ? User.getUserName(a.user) : ''; + const b_name = b && b.user ? User.getUserName(b.user) : ''; + return a_name.localeCompare(b_name); +}; + +export const isDenied = a => a && a.denied; + +export const isPending = a => a && !a.denied; + +export default { + compareUsername, + isDenied, + isPending, +}; diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index 25981d8..60354c2 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -1,3 +1,4 @@ +import Application from './Application'; import Participant from './Participant'; import Round from './Round'; @@ -15,6 +16,13 @@ export const findParticipant = (tournament, user) => { return tournament.participants.find(p => p.user_id == user.id); }; +export const getPendingApplications = tournament => { + if (!tournament || !tournament.applications || !tournament.applications.length) return []; + return tournament.applications + .filter(Application.isPending) + .sort(Application.compareUsername); +}; + export const getRunners = tournament => { if (!tournament || !tournament.participants || !tournament.participants.length) return []; return tournament.participants @@ -132,6 +140,16 @@ export const patchUser = (tournament, user) => { }; }; +export const removeApplication = (tournament, id) => { + if (!tournament || !tournament.applications || !tournament.applications.find(a => a.id == id)) { + return tournament; + } + return { + ...tournament, + applications: tournament.applications.filter(a => a.id != id), + }; +}; + export const sortParticipants = tournament => { if (!tournament || !tournament.participants || !tournament.participants.length) { return tournament; diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index cde0d05..18d03bf 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -58,6 +58,9 @@ export const mayApply = (user, tournament) => user && tournament && tournament.accept_applications && !isRunner(user, tournament) && !isApplicant(user, tournament); +export const mayHandleApplications = (user, tournament) => + tournament && tournament.accept_applications && isTournamentAdmin(user, tournament); + export const mayLockRound = (user, tournament) => !tournament.locked && isTournamentAdmin(user, tournament); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 76a5973..cc98394 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -1,6 +1,14 @@ /* eslint-disable max-len */ export default { translation: { + applications: { + accept: 'Annehmen', + acceptError: 'Fehler beim Annehmen', + acceptSuccess: 'Angenommen', + reject: 'Ablehnen', + rejectSuccess: 'Abgelehnt', + rejectError: 'Fehler beim Ablehnen', + }, button: { add: 'Hinzufügen', back: 'Zurück', @@ -125,6 +133,11 @@ export default { }, protocol: { description: { + application: { + accepted: 'Anmeldung von {{username}} bestätigt', + received: 'Anmeldung von {{username}} erhalten', + rejected: 'Anmeldung von {{username}} abgelehnt', + }, result: { comment: 'Ergebnis kommentiert: "{{details.result.comment}}"', report: 'Ergebnis von <0>{{time}} eingetragen', @@ -190,10 +203,12 @@ export default { admins: 'Organisation', applicationDenied: 'Antrag wurde abgelehnt', applicationPending: 'Antrag wurde abgeschickt', + applications: 'Anmeldungen', apply: 'Beitreten', applyError: 'Fehler beim Abschicken der Anfrage', applySuccess: 'Anfrage gestellt', monitors: 'Monitore', + noApplications: 'Derzeit keine Anmeldungen', noRecord: 'Turnier wird nicht gewertet', scoreboard: 'Scoreboard', }, diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 0b526ba..cd6dea0 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -1,6 +1,14 @@ /* eslint-disable max-len */ export default { translation: { + applications: { + accept: 'Accept', + acceptError: 'Error accepting', + acceptSuccess: 'Accepted', + reject: 'Reject', + rejectSuccess: 'Rejected', + rejectError: 'Error rejecting', + }, button: { add: 'Add', back: 'Back', @@ -125,6 +133,11 @@ export default { }, protocol: { description: { + application: { + accepted: 'Application from {{username}} accepted', + received: 'Application from {{username}} received', + rejected: 'Application from {{username}} rejected', + }, result: { comment: 'Result commented: "{{details.result.comment}}"', report: 'Result of {{time}} reported', @@ -190,10 +203,12 @@ export default { admins: 'Admins', applicationDenied: 'Application denied', applicationPending: 'Application pending', + applications: 'Applications', apply: 'Apply', applyError: 'Error submitting application', applySuccess: 'Application sent', monitors: 'Monitors', + noApplications: 'No applications at this point', noRecord: 'Tournament set to not be recorded', scoreboard: 'Scoreboard', }, diff --git a/routes/api.php b/routes/api.php index b9a4614..de8cd6c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) { return $request->user(); }); +Route::post('application/{application}/accept', 'App\Http\Controllers\ApplicationController@accept'); +Route::post('application/{application}/reject', 'App\Http\Controllers\ApplicationController@reject'); + Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament'); Route::post('results', 'App\Http\Controllers\ResultController@create');