--- /dev/null
+<?php
+
+namespace App\Events;
+
+use App\Models\Application;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ApplicationChanged implements ShouldBroadcast
+{
+ use Dispatchable, InteractsWithSockets, SerializesModels;
+
+ /**
+ * Create a new event instance.
+ *
+ * @return void
+ */
+ public function __construct(Application $application)
+ {
+ $this->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;
+
+}
--- /dev/null
+<?php
+
+namespace App\Events;
+
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ApplicationRemoved implements ShouldBroadcast
+{
+ use Dispatchable, InteractsWithSockets, SerializesModels;
+
+ /**
+ * Create a new event instance.
+ *
+ * @return void
+ */
+ public function __construct($application_id, $tournament_id)
+ {
+ $this->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;
+
+}
--- /dev/null
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Events\ApplicationChanged;
+use App\Events\ApplicationRemoved;
+use App\Models\Application;
+use App\Models\Participant;
+use App\Models\Protocol;
+use Illuminate\Http\Request;
+
+class ApplicationController extends Controller
+{
+
+ public function accept(Request $request, Application $application) {
+ $this->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();
+ }
+
+}
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;
$application->user_id = $request->user()->id;
$application->save();
ApplicationAdded::dispatch($application);
+ Protocol::applicationReceived($tournament, $application, $request->user());
return $tournament->toJson();
}
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);
'roles' => 'array',
];
+ protected $fillable = [
+ 'tournament_id',
+ 'user_id',
+ ];
+
protected $with = [
'user',
];
{
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,
}
+ protected static function applicationMemo(Application $application) {
+ return [
+ 'id' => $application->id,
+ 'denied' => $application->denied,
+ ];
+ }
+
protected static function resultMemo(Result $result) {
return [
'id' => $result->id,
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,
'username' => $user->username,
'discriminator' => $user->discriminator,
'avatar' => $user->avatar,
+ 'nickname' => $user->nickname,
];
}
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);
+ }
+
}
--- /dev/null
+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 <>
+ <Button
+ onClick={() => setShowDialog(true)}
+ title={i18n.t('tournaments.applications')}
+ variant="primary"
+ >
+ <Icon.APPLICATIONS title="" />
+ {pending.length ?
+ <>
+ {' '}
+ <Badge>{pending.length}</Badge>
+ </>
+ : null}
+ </Button>
+ <Dialog
+ onHide={() => 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));
--- /dev/null
+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 }) =>
+<Modal className="applications-dialog" onHide={onHide} show={show}>
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {i18n.t('tournaments.applications')}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body className="p-0">
+ {tournament.applications && tournament.applications.length ?
+ <List tournament={tournament} />
+ :
+ <Alert variant="info">
+ {i18n.t('tournaments.noApplications')}
+ </Alert>
+ }
+ </Modal.Body>
+ <Modal.Footer>
+ <Button onClick={onHide} variant="secondary">
+ {i18n.t('button.close')}
+ </Button>
+ </Modal.Footer>
+</Modal>;
+
+Dialog.propTypes = {
+ onHide: PropTypes.func,
+ show: PropTypes.bool,
+ tournament: PropTypes.shape({
+ applications: PropTypes.arrayOf(PropTypes.shape({
+ }))
+ }),
+};
+
+export default withTranslation()(Dialog);
--- /dev/null
+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 }) =>
+<ListGroup.Item className="d-flex justify-content-between align-items-center">
+ <Box discriminator user={application.user} />
+ <div className="button-bar">
+ <Button
+ onClick={() => accept(tournament, application)}
+ title={i18n.t('applications.accept')}
+ variant="success"
+ >
+ <Icon.ACCEPT title="" />
+ </Button>
+ <Button
+ onClick={() => reject(tournament, application)}
+ title={i18n.t('applications.reject')}
+ variant="danger"
+ >
+ <Icon.REJECT title="" />
+ </Button>
+ </div>
+</ListGroup.Item>;
+
+Item.propTypes = {
+ application: PropTypes.shape({
+ user: PropTypes.shape({
+ }),
+ }),
+ tournament: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(Item);
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+
+import Item from './Item';
+
+const List = ({ tournament }) =>
+<ListGroup variant="flush">
+ {tournament.applications.map(application =>
+ <Item application={application} key={application.id} tournament={tournament} />
+ )}
+</ListGroup>;
+
+List.propTypes = {
+ tournament: PropTypes.shape({
+ applications: PropTypes.arrayOf(PropTypes.shape({
+ })),
+ }),
+};
+
+export default List;
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');
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']);
patchResult,
patchRound,
patchUser,
+ removeApplication,
sortParticipants,
} from '../../helpers/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));
}
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 => {
: 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) || '?';
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 <Trans i18nKey={`protocol.description.${entry.type}`}>
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';
<div className="d-flex align-items-center justify-content-between">
<h1>{tournament.title}</h1>
<div className="button-bar">
+ <ApplicationsButton tournament={tournament} />
<ApplyButton tournament={tournament} />
{mayViewProtocol(user, tournament) ?
<Protocol id={tournament.id} />
--- /dev/null
+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,
+};
+import Application from './Application';
import Participant from './Participant';
import Round from './Round';
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
};
};
+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;
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);
/* 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',
},
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}}</0> eingetragen',
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',
},
/* 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',
},
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',
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',
},
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');