]> git.localhorst.tv Git - alttp.git/commitdiff
protocol frontend
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 11 Mar 2022 14:01:42 +0000 (15:01 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 11 Mar 2022 14:01:42 +0000 (15:01 +0100)
15 files changed:
app/Events/ProtocolAdded.php
app/Http/Controllers/ProtocolController.php
app/Models/Participant.php
app/Models/Tournament.php
app/Policies/TournamentPolicy.php
resources/js/components/common/Icon.js
resources/js/components/protocol/Dialog.js [new file with mode: 0644]
resources/js/components/protocol/Item.js [new file with mode: 0644]
resources/js/components/protocol/List.js [new file with mode: 0644]
resources/js/components/protocol/Protocol.js [new file with mode: 0644]
resources/js/components/tournament/Detail.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
routes/api.php
routes/channels.php

index 3abcb8639b843dd73832b0738018d10e7ab266de..f72386e02a8cefb33a0134a4cd82a87b81bcd525 100644 (file)
@@ -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;
index c7e3bd2051feb4aef6d179f7878d71bb077402b9..e4f028042212c9bede6d19fa09ccf378b2cce6d8 100644 (file)
@@ -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();
+       }
+
 }
index 36ab3cf305e18ff1d2992ae106b6bb4117c3810b..c2a8d8ab861525c1bdedfa7b75a1e420613e5d80 100644 (file)
@@ -17,4 +17,8 @@ class Participant extends Model
                return $this->belongsTo(User::class);
        }
 
+       protected $with = [
+               'user',
+       ];
+
 }
index a19e179ccaaeede4d127390ce831c34a1f0f5ae6..f8b4804859fd6b03447901574647cf91941ced9b 100644 (file)
@@ -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');
        }
 
 }
index 6352d714cba1fc7f174a8d56204ca64e6791e009..fd2a781e87db325e2322e517f7b1442dde0a55f8 100644 (file)
@@ -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';
+       }
+
 }
index d1376e3d9fcacbfef3ed1c9ea427350e195511f9..98317da1f846f86e1b87daca0a2c0684681eca89 100644 (file)
@@ -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 (file)
index 0000000..1b66c6c
--- /dev/null
@@ -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 <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);
diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js
new file mode 100644 (file)
index 0000000..6127a92
--- /dev/null
@@ -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 <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);
diff --git a/resources/js/components/protocol/List.js b/resources/js/components/protocol/List.js
new file mode 100644 (file)
index 0000000..55e0ecb
--- /dev/null
@@ -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 }) =>
+       <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;
diff --git a/resources/js/components/protocol/Protocol.js b/resources/js/components/protocol/Protocol.js
new file mode 100644 (file)
index 0000000..e94cad7
--- /dev/null
@@ -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 (
+               <>
+                       <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);
index 60c9d245716439653296fea58decc6659f4b8ae8..912d2a26d58df7a0aedcdfd87a3249353bc1b949 100644 (file)
@@ -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 = ({
 }) => <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>
@@ -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({
index ecc1e620e2796fdef58275cab0ed57bed8131fb8..891803110ace4d65b34a7acb8d514a9f6676cd6e 100644 (file)
@@ -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);
+
index 033059b40f7ef0f10907461526d2e8756e137d52..ff78e7a1acab8e351d09f011baf248de5fb709bc 100644 (file)
@@ -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 }}',
                },
index 3ce6b1d15cd022d2eec6aef8a10ce80d0fb9f53e..d40f3e45fdfb57324486c7f55d8e30aa929553ac 100644 (file)
@@ -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');
index 4581feb7df93cf85c1b5501370046d7c1e6cc18a..e93d25e9c284e4afa7c4c91876f6688e97133590 100644 (file)
@@ -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;