From c30ac282dde3d746d6a7762ee18c70b4416500b5 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 11 Mar 2022 15:01:42 +0100 Subject: [PATCH] protocol frontend --- app/Events/ProtocolAdded.php | 2 +- app/Http/Controllers/ProtocolController.php | 13 +++- app/Models/Participant.php | 4 ++ app/Models/Tournament.php | 4 +- app/Policies/TournamentPolicy.php | 12 ++++ resources/js/components/common/Icon.js | 1 + resources/js/components/protocol/Dialog.js | 66 ++++++++++++++++++++ resources/js/components/protocol/Item.js | 64 +++++++++++++++++++ resources/js/components/protocol/List.js | 23 +++++++ resources/js/components/protocol/Protocol.js | 58 +++++++++++++++++ resources/js/components/tournament/Detail.js | 10 ++- resources/js/helpers/permissions.js | 4 ++ resources/js/i18n/de.js | 19 ++++++ routes/api.php | 2 + routes/channels.php | 5 ++ 15 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 resources/js/components/protocol/Dialog.js create mode 100644 resources/js/components/protocol/Item.js create mode 100644 resources/js/components/protocol/List.js create mode 100644 resources/js/components/protocol/Protocol.js diff --git a/app/Events/ProtocolAdded.php b/app/Events/ProtocolAdded.php index 3abcb86..f72386e 100644 --- a/app/Events/ProtocolAdded.php +++ b/app/Events/ProtocolAdded.php @@ -33,7 +33,7 @@ class ProtocolAdded implements ShouldBroadcast */ public function broadcastOn() { - return new PrivateChannel('Tournament.'.$this->protocol->tournament_id); + return new PrivateChannel('Protocol.'.$this->protocol->tournament_id); } public $protocol; diff --git a/app/Http/Controllers/ProtocolController.php b/app/Http/Controllers/ProtocolController.php index c7e3bd2..e4f0280 100644 --- a/app/Http/Controllers/ProtocolController.php +++ b/app/Http/Controllers/ProtocolController.php @@ -2,9 +2,20 @@ 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(); + } + } diff --git a/app/Models/Participant.php b/app/Models/Participant.php index 36ab3cf..c2a8d8a 100644 --- a/app/Models/Participant.php +++ b/app/Models/Participant.php @@ -17,4 +17,8 @@ class Participant extends Model return $this->belongsTo(User::class); } + protected $with = [ + 'user', + ]; + } diff --git a/app/Models/Tournament.php b/app/Models/Tournament.php index a19e179..f8b4804 100644 --- a/app/Models/Tournament.php +++ b/app/Models/Tournament.php @@ -14,11 +14,11 @@ class Tournament extends Model } 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'); } } diff --git a/app/Policies/TournamentPolicy.php b/app/Policies/TournamentPolicy.php index 6352d71..fd2a781 100644 --- a/app/Policies/TournamentPolicy.php +++ b/app/Policies/TournamentPolicy.php @@ -104,4 +104,16 @@ class TournamentPolicy 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'; + } + } diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index d1376e3..98317da 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -61,5 +61,6 @@ const makePreset = (presetDisplayName, presetName) => { Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']); Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt'); +Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt'); export default Icon; diff --git a/resources/js/components/protocol/Dialog.js b/resources/js/components/protocol/Dialog.js new file mode 100644 index 0000000..1b66c6c --- /dev/null +++ b/resources/js/components/protocol/Dialog.js @@ -0,0 +1,66 @@ +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 + + + {i18n.t('protocol.heading')} + + + {protocol && protocol.length ? + + : + + + {i18n.t('protocol.empty')} + + + } + + + + ; + } + +} + +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); diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js new file mode 100644 index 0000000..6127a92 --- /dev/null +++ b/resources/js/components/protocol/Item.js @@ -0,0 +1,64 @@ +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 ; + } +}; + +const Item = ({ entry }) => + +
+ {getEntryIcon(entry)} +
+
+
+ {getEntryDescription(entry)} +
+
+ {getEntryDate(entry)} +
+
+
; + +Item.propTypes = { + entry: PropTypes.shape({ + created_at: PropTypes.string, + }), +}; + +Item.defaultProps = { + entry: {}, +}; + +export default withTranslation()(Item); diff --git a/resources/js/components/protocol/List.js b/resources/js/components/protocol/List.js new file mode 100644 index 0000000..55e0ecb --- /dev/null +++ b/resources/js/components/protocol/List.js @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { ListGroup } from 'react-bootstrap'; + +import Item from './Item'; + +const List = ({ protocol }) => + + {protocol ? protocol.map(entry => + + ) : null} + ; + +List.propTypes = { + protocol: PropTypes.arrayOf(PropTypes.shape({ + })), +}; + +List.defaultProps = { + protocol: [], +}; + +export default List; diff --git a/resources/js/components/protocol/Protocol.js b/resources/js/components/protocol/Protocol.js new file mode 100644 index 0000000..e94cad7 --- /dev/null +++ b/resources/js/components/protocol/Protocol.js @@ -0,0 +1,58 @@ +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 ( + <> + + setShowDialog(false)} + protocol={protocol} + show={showDialog} + /> + + ); +}; + +Protocol.propTypes = { + id: PropTypes.number, +}; + +export default withTranslation()(Protocol); diff --git a/resources/js/components/tournament/Detail.js b/resources/js/components/tournament/Detail.js index 60c9d24..912d2a2 100644 --- a/resources/js/components/tournament/Detail.js +++ b/resources/js/components/tournament/Detail.js @@ -4,8 +4,12 @@ import { Button, Container } from 'react-bootstrap'; 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'; @@ -16,6 +20,9 @@ const Detail = ({ }) =>

{tournament.title}

+ {mayViewProtocol(user, tournament) ? + + : null}

{i18n.t('participants.heading')}

@@ -39,6 +46,7 @@ const Detail = ({ Detail.propTypes = { addRound: PropTypes.func, tournament: PropTypes.shape({ + id: PropTypes.number, participants: PropTypes.arrayOf(PropTypes.shape({ })), rounds: PropTypes.arrayOf(PropTypes.shape({ diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index ecc1e62..8918031 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -13,3 +13,7 @@ export const isParticipant = (user, tournament) => export const mayAddRounds = (user, tournament) => isAdmin(user) || isParticipant(user, tournament); + +export const mayViewProtocol = user => + isAdmin(user); + diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 033059b..ff78e7a 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -2,8 +2,17 @@ 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', @@ -16,6 +25,16 @@ export default { 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 }}', }, diff --git a/routes/api.php b/routes/api.php index 3ce6b1d..d40f3e4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,8 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) { 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'); diff --git a/routes/channels.php b/routes/channels.php index 4581feb..e93d25e 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -22,6 +22,11 @@ Broadcast::channel('App.Control', function ($user) { 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; -- 2.39.2