*/
public function broadcastOn()
{
- return new PrivateChannel('Tournament.'.$this->protocol->tournament_id);
+ return new PrivateChannel('Protocol.'.$this->protocol->tournament_id);
}
public $protocol;
namespace App\Http\Controllers;
+use App\Models\Tournament;
use Illuminate\Http\Request;
class ProtocolController extends Controller
{
- //
+
+ public function forTournament(Tournament $tournament) {
+ $this->authorize('viewProtocol', $tournament);
+ $protocol = $tournament
+ ->protocols()
+ ->with('user')
+ ->orderBy('created_at', 'desc')
+ ->get();
+ return $protocol->values()->toJson();
+ }
+
}
return $this->belongsTo(User::class);
}
+ protected $with = [
+ 'user',
+ ];
+
}
}
public function protocols() {
- return $this->hasMany(Protocol::class);
+ return $this->hasMany(Protocol::class)->orderBy('created_at', 'DESC');
}
public function rounds() {
- return $this->hasMany(Round::class);
+ return $this->hasMany(Round::class)->orderBy('created_at');
}
}
return $user->role === 'admin' || $user->isParticipant($tournament);
}
+ /**
+ * Determine whether the user can view the tournament protocol.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\Tournament $tournament
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function viewProtocol(User $user, Tournament $tournament)
+ {
+ return $user->role === 'admin';
+ }
+
}
Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
+Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
export default Icon;
--- /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';
+
+class Dialog extends React.Component {
+
+ componentDidMount() {
+ this.timer = setInterval(() => {
+ this.forceUpdate();
+ }, 30000);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ }
+
+ render() {
+ const {
+ onHide,
+ protocol,
+ show,
+ } = this.props;
+ return <Modal className="protocol-dialog" onHide={onHide} show={show} size="lg">
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {i18n.t('protocol.heading')}
+ </Modal.Title>
+ </Modal.Header>
+ {protocol && protocol.length ?
+ <List protocol={protocol} />
+ :
+ <Modal.Body>
+ <Alert variant="info">
+ {i18n.t('protocol.empty')}
+ </Alert>
+ </Modal.Body>
+ }
+ <Modal.Footer>
+ <Button onClick={onHide} variant="secondary">
+ {i18n.t('button.close')}
+ </Button>
+ </Modal.Footer>
+ </Modal>;
+ }
+
+}
+
+Dialog.propTypes = {
+ onHide: PropTypes.func,
+ protocol: PropTypes.arrayOf(PropTypes.shape({
+ type: PropTypes.string,
+ })),
+ show: PropTypes.bool,
+};
+
+Dialog.defaultProps = {
+ onHide: null,
+ protocol: null,
+ show: false,
+};
+
+export default withTranslation()(Dialog);
--- /dev/null
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { ListGroup } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Icon from '../common/Icon';
+import i18n from '../../i18n';
+
+const getEntryDate = entry => {
+ const dateStr = moment(entry.created_at).fromNow();
+ return entry.user
+ ? `${entry.user.username} ${dateStr}`
+ : dateStr;
+};
+
+const getEntryDescription = entry => {
+ switch (entry.type) {
+ case 'round.create':
+ return i18n.t(
+ `protocol.description.${entry.type}`,
+ entry,
+ );
+ default:
+ return i18n.t('protocol.description.unknown', entry);
+ }
+};
+
+const getEntryIcon = entry => {
+ switch (entry.type) {
+ default:
+ return <Icon.PROTOCOL />;
+ }
+};
+
+const Item = ({ entry }) =>
+ <ListGroup.Item className="d-flex align-items-center">
+ <div className="pe-3 text-muted">
+ {getEntryIcon(entry)}
+ </div>
+ <div>
+ <div>
+ {getEntryDescription(entry)}
+ </div>
+ <div
+ className="text-muted"
+ title={moment(entry.created_at).format('LLLL')}
+ >
+ {getEntryDate(entry)}
+ </div>
+ </div>
+ </ListGroup.Item>;
+
+Item.propTypes = {
+ entry: PropTypes.shape({
+ created_at: PropTypes.string,
+ }),
+};
+
+Item.defaultProps = {
+ entry: {},
+};
+
+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 = ({ protocol }) =>
+ <ListGroup variant="flush">
+ {protocol ? protocol.map(entry =>
+ <Item key={entry.id} entry={entry} />
+ ) : null}
+ </ListGroup>;
+
+List.propTypes = {
+ protocol: PropTypes.arrayOf(PropTypes.shape({
+ })),
+};
+
+List.defaultProps = {
+ protocol: [],
+};
+
+export default List;
--- /dev/null
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useEffect, useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import Dialog from './Dialog';
+import Icon from '../common/Icon';
+import i18n from '../../i18n';
+
+const Protocol = ({ id }) => {
+ const [showDialog, setShowDialog] = useState(false);
+ const [protocol, setProtocol] = useState([]);
+
+ useEffect(() => {
+ axios
+ .get(`/api/protocol/${id}`)
+ .then(response => {
+ setProtocol(response.data);
+ });
+ }, [id]);
+
+ useEffect(() => {
+ window.Echo.private(`Protocol.${id}`)
+ .listen('ProtocolAdded', e => {
+ console.log(e);
+ if (e.protocol) {
+ setProtocol(protocol => [e.protocol, ...protocol]);
+ }
+ });
+ return () => {
+ window.Echo.leave(`Protocol.${id}`);
+ };
+ }, [id]);
+
+ return (
+ <>
+ <Button
+ onClick={() => setShowDialog(true)}
+ title={i18n.t('button.protocol')}
+ variant="outline-info"
+ >
+ <Icon.PROTOCOL title="" />
+ </Button>
+ <Dialog
+ onHide={() => setShowDialog(false)}
+ protocol={protocol}
+ show={showDialog}
+ />
+ </>
+ );
+};
+
+Protocol.propTypes = {
+ id: PropTypes.number,
+};
+
+export default withTranslation()(Protocol);
import { withTranslation } from 'react-i18next';
import Participants from '../participants/List';
+import Protocol from '../protocol/Protocol';
import Rounds from '../rounds/List';
-import { mayAddRounds } from '../../helpers/permissions';
+import {
+ mayAddRounds,
+ mayViewProtocol,
+} from '../../helpers/permissions';
import { withUser } from '../../helpers/UserContext';
import i18n from '../../i18n';
}) => <Container>
<div className="d-flex align-items-center justify-content-between">
<h1>{tournament.title}</h1>
+ {mayViewProtocol(user, tournament) ?
+ <Protocol id={tournament.id} />
+ : null}
</div>
<div className="d-flex align-items-center justify-content-between">
<h2>{i18n.t('participants.heading')}</h2>
Detail.propTypes = {
addRound: PropTypes.func,
tournament: PropTypes.shape({
+ id: PropTypes.number,
participants: PropTypes.arrayOf(PropTypes.shape({
})),
rounds: PropTypes.arrayOf(PropTypes.shape({
export const mayAddRounds = (user, tournament) =>
isAdmin(user) || isParticipant(user, tournament);
+
+export const mayViewProtocol = user =>
+ isAdmin(user);
+
export default {
translation: {
button: {
+ add: 'Hinzufügen',
+ back: 'Zurück',
+ close: 'Schließen',
+ edit: 'Bearbeiten',
+ help: 'Hilfe',
login: 'Login',
logout: 'Logout',
+ new: 'Neu',
+ protocol: 'Protokoll',
+ save: 'Speichern',
+ search: 'Suche',
},
general: {
appName: 'ALttP',
empty: 'Noch keine Teilnehmer eingetragen',
heading: 'Teilnehmer',
},
+ protocol: {
+ description: {
+ round: {
+ create: 'Runde hinzugefügt',
+ },
+ unknown: 'Unbekannter Protokolleintrag vom Typ {{type}}.',
+ },
+ empty: 'Leider nix',
+ heading: 'Protokoll',
+ },
results: {
time: 'Zeit: {{ time }}',
},
return $request->user();
});
+Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament');
+
Route::post('rounds', 'App\Http\Controllers\RoundController@create');
Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');
return true;
});
+Broadcast::channel('Protocol.{id}', function ($user, $id) {
+ $tournament = Tournament::findOrFail($id);
+ return $user->can('viewProtocol', $tournament);
+});
+
Broadcast::channel('Tournament.{id}', function ($user, $id) {
$tournament = Tournament::findOrFail($id);
return true;