From 920f11ddfeb2175e4e1556886773dcd044c6085b Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Sun, 20 Mar 2022 15:50:36 +0100 Subject: [PATCH] allow users to set their stream link --- app/Events/UserChanged.php | 43 +++++++ app/Http/Controllers/UserController.php | 23 ++++ app/Policies/UserPolicy.php | 106 ++++++++++++++++++ resources/js/components/App.js | 2 + resources/js/components/pages/Tournament.js | 16 ++- resources/js/components/pages/User.js | 66 +++++++++++ resources/js/components/results/ReportForm.js | 4 +- resources/js/components/rounds/SeedForm.js | 27 +++-- .../components/users/EditStreamLinkButton.js | 41 +++++++ .../components/users/EditStreamLinkDialog.js | 33 ++++++ .../js/components/users/EditStreamLinkForm.js | 101 +++++++++++++++++ resources/js/components/users/Profile.js | 48 ++++++++ resources/js/helpers/Participant.js | 13 +++ resources/js/helpers/Tournament.js | 11 ++ resources/js/helpers/permissions.js | 5 + resources/js/i18n/de.js | 23 ++++ resources/js/i18n/en.js | 23 ++++ resources/sass/common.scss | 7 ++ routes/api.php | 2 + 19 files changed, 583 insertions(+), 11 deletions(-) create mode 100644 app/Events/UserChanged.php create mode 100644 app/Policies/UserPolicy.php create mode 100644 resources/js/components/pages/User.js create mode 100644 resources/js/components/users/EditStreamLinkButton.js create mode 100644 resources/js/components/users/EditStreamLinkDialog.js create mode 100644 resources/js/components/users/EditStreamLinkForm.js create mode 100644 resources/js/components/users/Profile.js diff --git a/app/Events/UserChanged.php b/app/Events/UserChanged.php new file mode 100644 index 0000000..9009c44 --- /dev/null +++ b/app/Events/UserChanged.php @@ -0,0 +1,43 @@ +user = $user; + } + + /** + * Get the channels the event should broadcast on. + * + * @return \Illuminate\Broadcasting\Channel|array + */ + public function broadcastOn() + { + return [ + new Channel('App.Control'), + new PrivateChannel('App.Models.User.'.$this->user->id), + ]; + } + + public $user; + +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 58d725d..c0ab73d 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Events\UserChanged; +use App\Models\User; use Illuminate\Http\Request; class UserController extends Controller @@ -21,4 +23,25 @@ class UserController extends Controller return $user->toJson(); } + public function setStreamLink(Request $request, User $user) { + $this->authorize('setStreamLink', $user); + + $validatedData = $request->validate([ + 'stream_link' => 'required|url', + ]); + + $user->stream_link = $validatedData['stream_link']; + $user->update(); + + UserChanged::dispatch($user); + + return $user->toJson(); + } + + public function single(Request $request, $id) { + $user = User::findOrFail($id); + $this->authorize('view', $user); + return $user->toJson(); + } + } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..67bc561 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,106 @@ +id === $model->id; + } + + /** + * Determine whether the user can delete the model. + * + * @param \App\Models\User $user + * @param \App\Models\User $model + * @return \Illuminate\Auth\Access\Response|bool + */ + public function delete(User $user, User $model) + { + return false; + } + + /** + * Determine whether the user can restore the model. + * + * @param \App\Models\User $user + * @param \App\Models\User $model + * @return \Illuminate\Auth\Access\Response|bool + */ + public function restore(User $user, User $model) + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + * + * @param \App\Models\User $user + * @param \App\Models\User $model + * @return \Illuminate\Auth\Access\Response|bool + */ + public function forceDelete(User $user, User $model) + { + return false; + } + + /** + * Determine whether the user change the stream link of the model. + * + * @param \App\Models\User $user + * @param \App\Models\User $model + * @return \Illuminate\Auth\Access\Response|bool + */ + public function setStreamLink(User $user, User $model) + { + return $user->role == 'admin' || $user->id == $model->id; + } + +} diff --git a/resources/js/components/App.js b/resources/js/components/App.js index 0199b93..d914d21 100644 --- a/resources/js/components/App.js +++ b/resources/js/components/App.js @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import Header from './common/Header'; import Tournament from './pages/Tournament'; +import User from './pages/User'; import UserContext from '../helpers/UserContext'; const App = () => { @@ -51,6 +52,7 @@ const App = () => {
} /> + } /> } /> diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js index 11dc388..6e32227 100644 --- a/resources/js/components/pages/Tournament.js +++ b/resources/js/components/pages/Tournament.js @@ -7,7 +7,7 @@ import ErrorMessage from '../common/ErrorMessage'; import Loading from '../common/Loading'; import NotFound from '../pages/NotFound'; import Detail from '../tournament/Detail'; -import { patchResult, patchRound, sortParticipants } from '../../helpers/Tournament'; +import { patchResult, patchRound, patchUser, sortParticipants } from '../../helpers/Tournament'; const Tournament = () => { const params = useParams(); @@ -63,6 +63,20 @@ const Tournament = () => { }; }, [id]); + useEffect(() => { + const cb = (e) => { + if (e.user) { + setTournament(tournament => patchUser(tournament, e.user)); + } + }; + window.Echo.channel('App.Control') + .listen('UserChanged', cb); + return () => { + window.Echo.channel('App.Control') + .stopListening('UserChanged', cb); + }; + }, []); + if (loading) { return ; } diff --git a/resources/js/components/pages/User.js b/resources/js/components/pages/User.js new file mode 100644 index 0000000..8dfdba4 --- /dev/null +++ b/resources/js/components/pages/User.js @@ -0,0 +1,66 @@ +import axios from 'axios'; +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import ErrorBoundary from '../common/ErrorBoundary'; +import ErrorMessage from '../common/ErrorMessage'; +import Loading from '../common/Loading'; +import NotFound from '../pages/NotFound'; +import Profile from '../users/Profile'; + +const User = () => { + const params = useParams(); + const { id } = params; + + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [user, setUser] = useState(null); + + useEffect(() => { + setLoading(true); + axios + .get(`/api/users/${id}`) + .then(response => { + setError(null); + setLoading(false); + setUser(response.data); + }) + .catch(error => { + setError(error); + setLoading(false); + setUser(null); + }); + }, [id]); + + useEffect(() => { + const cb = (e) => { + if (e.user) { + setUser(user => e.user.id === user.id ? { ...user, ...e.user } : user); + } + }; + window.Echo.channel('App.Control') + .listen('UserChanged', cb); + return () => { + window.Echo.channel('App.Control') + .stopListening('UserChanged', cb); + }; + }, []); + + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!user) { + return ; + } + + return + + ; +}; + +export default User; diff --git a/resources/js/components/results/ReportForm.js b/resources/js/components/results/ReportForm.js index e7d1d9d..943aac3 100644 --- a/resources/js/components/results/ReportForm.js +++ b/resources/js/components/results/ReportForm.js @@ -7,10 +7,10 @@ import { withTranslation } from 'react-i18next'; import toastr from 'toastr'; import LargeCheck from '../common/LargeCheck'; -import i18n from '../../i18n'; import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik'; import { findResult } from '../../helpers/Participant'; import { formatTime, parseTime } from '../../helpers/Result'; +import i18n from '../../i18n'; import yup from '../../schema/yup'; const ReportForm = ({ @@ -114,7 +114,7 @@ export default withFormik({ onCancel(); } } catch (e) { - toastr.success(i18n.t('results.reportError')); + toastr.error(i18n.t('results.reportError')); if (e.response && e.response.data && e.response.data.errors) { setErrors(laravelErrorsToFormik(e.response.data.errors)); } diff --git a/resources/js/components/rounds/SeedForm.js b/resources/js/components/rounds/SeedForm.js index e21da2e..3cff560 100644 --- a/resources/js/components/rounds/SeedForm.js +++ b/resources/js/components/rounds/SeedForm.js @@ -4,11 +4,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; +import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik'; import i18n from '../../i18n'; import yup from '../../schema/yup'; -const ReportForm = ({ +const SeedForm = ({ errors, handleBlur, handleChange, @@ -51,7 +53,7 @@ const ReportForm = ({ ; -ReportForm.propTypes = { +SeedForm.propTypes = { errors: PropTypes.shape({ seed: PropTypes.string, }), @@ -72,12 +74,21 @@ export default withFormik({ enableReinitialize: true, handleSubmit: async (values, actions) => { const { round_id, seed } = values; + const { setErrors } = actions; const { onCancel } = actions.props; - await axios.post(`/api/rounds/${round_id}/setSeed`, { - seed, - }); - if (onCancel) { - onCancel(); + try { + await axios.post(`/api/rounds/${round_id}/setSeed`, { + seed, + }); + toastr.success(i18n.t('rounds.setSeedSuccess')); + if (onCancel) { + onCancel(); + } + } catch (e) { + toastr.error(i18n.t('rounds.setSeedError')); + if (e.response && e.response.data && e.response.data.errors) { + setErrors(laravelErrorsToFormik(e.response.data.errors)); + } } }, mapPropsToValues: ({ round }) => ({ @@ -87,4 +98,4 @@ export default withFormik({ validationSchema: yup.object().shape({ seed: yup.string().required().url(), }), -})(withTranslation()(ReportForm)); +})(withTranslation()(SeedForm)); diff --git a/resources/js/components/users/EditStreamLinkButton.js b/resources/js/components/users/EditStreamLinkButton.js new file mode 100644 index 0000000..a1c8c24 --- /dev/null +++ b/resources/js/components/users/EditStreamLinkButton.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import EditStreamLinkDialog from './EditStreamLinkDialog'; +import Icon from '../common/Icon'; +import { mayEditStreamLink } from '../../helpers/permissions'; +import { withUser } from '../../helpers/UserContext'; +import i18n from '../../i18n'; + +const EditStreamLinkButton = ({ authUser, user }) => { + const [showDialog, setShowDialog] = useState(false); + + if (mayEditStreamLink(authUser, user)) { + return <> + setShowDialog(false)} + show={showDialog} + user={user} + /> + + ; + } + return null; +}; + +EditStreamLinkButton.propTypes = { + authUser: PropTypes.shape({ + }), + user: PropTypes.shape({ + }), +}; + +export default withTranslation()(withUser(EditStreamLinkButton, 'authUser')); diff --git a/resources/js/components/users/EditStreamLinkDialog.js b/resources/js/components/users/EditStreamLinkDialog.js new file mode 100644 index 0000000..34db12e --- /dev/null +++ b/resources/js/components/users/EditStreamLinkDialog.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import EditStreamLinkForm from './EditStreamLinkForm'; +import i18n from '../../i18n'; + +const EditStreamLinkDialog = ({ + onHide, + show, + user, +}) => + + + + {i18n.t('users.editStreamLink')} + + + +; + +EditStreamLinkDialog.propTypes = { + onHide: PropTypes.func, + show: PropTypes.bool, + user: PropTypes.shape({ + }), +}; + +export default withTranslation()(EditStreamLinkDialog); diff --git a/resources/js/components/users/EditStreamLinkForm.js b/resources/js/components/users/EditStreamLinkForm.js new file mode 100644 index 0000000..78219ac --- /dev/null +++ b/resources/js/components/users/EditStreamLinkForm.js @@ -0,0 +1,101 @@ +import axios from 'axios'; +import { withFormik } from 'formik'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; +import toastr from 'toastr'; + +import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik'; +import i18n from '../../i18n'; +import yup from '../../schema/yup'; + +const EditStreamLinkForm = ({ + errors, + handleBlur, + handleChange, + handleSubmit, + onCancel, + touched, + values, +}) => +
+ + + + {i18n.t('users.streamLink')} + + {touched.stream_link && errors.stream_link ? + + {i18n.t(errors.stream_link)} + + : null} + + + + + {onCancel ? + + : null} + + +
; + +EditStreamLinkForm.propTypes = { + errors: PropTypes.shape({ + stream_link: PropTypes.string, + }), + handleBlur: PropTypes.func, + handleChange: PropTypes.func, + handleSubmit: PropTypes.func, + onCancel: PropTypes.func, + touched: PropTypes.shape({ + stream_link: PropTypes.bool, + }), + values: PropTypes.shape({ + stream_link: PropTypes.string, + }), +}; + +export default withFormik({ + displayName: 'SeedForm', + enableReinitialize: true, + handleSubmit: async (values, actions) => { + const { user_id, stream_link } = values; + const { setErrors } = actions; + const { onCancel } = actions.props; + try { + await axios.post(`/api/users/${user_id}/setStreamLink`, { + stream_link, + }); + toastr.success(i18n.t('users.setStreamLinkSuccess')); + if (onCancel) { + onCancel(); + } + } catch (e) { + toastr.error(i18n.t('users.setStreamLinkError')); + if (e.response && e.response.data && e.response.data.errors) { + setErrors(laravelErrorsToFormik(e.response.data.errors)); + } + } + }, + mapPropsToValues: ({ user }) => ({ + user_id: user.id, + stream_link: user.stream_link || '', + }), + validationSchema: yup.object().shape({ + stream_link: yup.string().required().url(), + }), +})(withTranslation()(EditStreamLinkForm)); diff --git a/resources/js/components/users/Profile.js b/resources/js/components/users/Profile.js new file mode 100644 index 0000000..f38ba2f --- /dev/null +++ b/resources/js/components/users/Profile.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Col, Container, Row } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import Box from './Box'; +import EditStreamLinkButton from './EditStreamLinkButton'; +import Icon from '../common/Icon'; +import i18n from '../../i18n'; + +const Profile = ({ user }) => +

{user.username}

+ + +

{i18n.t('users.discordTag')}

+ + + +

{i18n.t('users.streamLink')}

+

+ {user.stream_link ? + + : + i18n.t('users.noStream') + } + {' '} + +

+ +
+
; + +Profile.propTypes = { + user: PropTypes.shape({ + stream_link: PropTypes.string, + username: PropTypes.string, + }), +}; + +export default withTranslation()(Profile); diff --git a/resources/js/helpers/Participant.js b/resources/js/helpers/Participant.js index a44d465..eb94340 100644 --- a/resources/js/helpers/Participant.js +++ b/resources/js/helpers/Participant.js @@ -40,6 +40,18 @@ export const findResult = (participant, round) => { return round.results.find(result => result.user_id === participant.user_id); }; +export const patchUser = (participant, user) => { + if (!participant || !user) return participant; + if (participant.user_id != user.id) return participant; + return { + ...participant, + user: { + ...participant.user, + ...user, + }, + }; +}; + export const sortByResult = (participants, round) => { if (!participants || !participants.length) return participants; if (!round || !round.results || !round.results.length) return participants; @@ -50,5 +62,6 @@ export default { compareResult, compareUsername, findResult, + patchUser, sortByResult, }; diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js index ff1a063..a3f97cc 100644 --- a/resources/js/helpers/Tournament.js +++ b/resources/js/helpers/Tournament.js @@ -68,6 +68,15 @@ export const patchRound = (tournament, round) => { }; }; +export const patchUser = (tournament, user) => { + if (!tournament || !tournament.participants || !user) return tournament; + if (!tournament.participants.find(p => p.user_id == user.id)) return tournament; + return { + ...tournament, + participants: tournament.participants.map(p => Participant.patchUser(p, user)), + }; +}; + export const sortParticipants = tournament => { if (!tournament || !tournament.participants || !tournament.participants.length) { return tournament; @@ -83,5 +92,7 @@ export default { compareScore, findParticipant, patchResult, + patchRound, + patchUser, sortParticipants, }; diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 769edda..23272d9 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -28,3 +28,8 @@ export const mayViewProtocol = user => export const maySeeResults = (user, tournament, round) => isAdmin(user) || hasFinished(user, round) || Round.isComplete(tournament, round); + +// Users + +export const mayEditStreamLink = (user, subject) => + isAdmin(user) || isSameUser(user, subject); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index e7c5086..6feeb64 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -15,7 +15,22 @@ export default { save: 'Speichern', search: 'Suche', }, + error: { + 403: { + description: 'So aber nicht', + heading: 'Zugriff verweigert', + }, + 404: { + description: 'Das war aber irgendwo', + heading: 'Nicht gefunden', + }, + 500: { + description: 'NotLikeThis', + heading: 'Serverfehler', + }, + }, general: { + anonymous: 'Anonym', appName: 'ALttP', }, icon: { @@ -121,12 +136,20 @@ export default { noSeed: 'Noch kein Seed', seed: 'Seed', setSeed: 'Seed eintragen', + setSeedError: 'Seed konnte nicht eintragen werden', + setSeedSuccess: 'Seed eingetragen', }, tournaments: { scoreboard: 'Scoreboard', }, users: { + discordTag: 'Discord Tag', + editStreamLink: 'Stream Link bearbeiten', + noStream: 'Kein Stream gesetzt', + setStreamLinkError: 'Konnte Stream Link nicht speichern', + setStreamLinkSuccess: 'Stream Link gespeichert', stream: 'Stream', + streamLink: 'Stream Link', }, validation: { error: { diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 0f8b744..3f01805 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -15,7 +15,22 @@ export default { save: 'Save', search: 'Search', }, + error: { + 403: { + description: 'Um no', + heading: 'Access denied', + }, + 404: { + description: 'Pretty sure I had that somehere', + heading: 'Not found', + }, + 500: { + description: 'NotLikeThis', + heading: 'Server error', + }, + }, general: { + anonymous: 'Anonym', appName: 'ALttP', }, icon: { @@ -121,12 +136,20 @@ export default { noSeed: 'No seed set', seed: 'Seed', setSeed: 'Set seed', + setSeedError: 'Seed could not be set', + setSeedSuccess: 'Seed set', }, tournaments: { scoreboard: 'Scoreboard', }, users: { + discordTag: 'Discord tag', + editStreamLink: 'Edit stream link', + noStream: 'No stream set', + setStreamLinkError: 'Could not save stream link', + setStreamLinkSuccess: 'Stream link saved', stream: 'Stream', + streamLink: 'Stream link', }, validation: { error: { diff --git a/resources/sass/common.scss b/resources/sass/common.scss index 57d0d5f..80b8b2a 100644 --- a/resources/sass/common.scss +++ b/resources/sass/common.scss @@ -11,6 +11,13 @@ h1 { margin-bottom: 2rem; } +.text-discord { + color: $discord; +} +.text-twitch { + color: $twitch; +} + .text-gold { color: $gold; } diff --git a/routes/api.php b/routes/api.php index 4c55f75..ab79dce 100644 --- a/routes/api.php +++ b/routes/api.php @@ -27,4 +27,6 @@ Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setS Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single'); +Route::get('users/{id}', 'App\Http\Controllers\UserController@single'); Route::post('users/set-language', 'App\Http\Controllers\UserController@setLanguage'); +Route::post('users/{user}/setStreamLink', 'App\Http\Controllers\UserController@setStreamLink'); -- 2.39.2