--- /dev/null
+<?php
+
+namespace App\Events;
+
+use App\Models\User;
+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 UserChanged implements ShouldBroadcast
+{
+ use Dispatchable, InteractsWithSockets, SerializesModels;
+
+ /**
+ * Create a new event instance.
+ *
+ * @return void
+ */
+ public function __construct(User $user)
+ {
+ $this->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;
+
+}
namespace App\Http\Controllers;
+use App\Events\UserChanged;
+use App\Models\User;
use Illuminate\Http\Request;
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();
+ }
+
}
--- /dev/null
+<?php
+
+namespace App\Policies;
+
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class UserPolicy
+{
+ use HandlesAuthorization;
+
+ /**
+ * Determine whether the user can view any models.
+ *
+ * @param \App\Models\User $user
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function viewAny(User $user)
+ {
+ return true;
+ }
+
+ /**
+ * Determine whether the user can view the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\User $model
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function view(User $user, User $model)
+ {
+ return true;
+ }
+
+ /**
+ * Determine whether the user can create models.
+ *
+ * @param \App\Models\User $user
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function create(User $user)
+ {
+ return false;
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ *
+ * @param \App\Models\User $user
+ * @param \App\Models\User $model
+ * @return \Illuminate\Auth\Access\Response|bool
+ */
+ public function update(User $user, User $model)
+ {
+ return $user->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;
+ }
+
+}
import Header from './common/Header';
import Tournament from './pages/Tournament';
+import User from './pages/User';
import UserContext from '../helpers/UserContext';
const App = () => {
<Header doLogout={doLogout} />
<Routes>
<Route path="tournaments/:id" element={<Tournament />} />
+ <Route path="users/:id" element={<User />} />
<Route path="*" element={<Navigate to="/tournaments/1" />} />
</Routes>
</UserContext.Provider>
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();
};
}, [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 <Loading />;
}
--- /dev/null
+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 <Loading />;
+ }
+
+ if (error) {
+ return <ErrorMessage error={error} />;
+ }
+
+ if (!user) {
+ return <NotFound />;
+ }
+
+ return <ErrorBoundary>
+ <Profile user={user} />
+ </ErrorBoundary>;
+};
+
+export default User;
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 = ({
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));
}
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,
</Modal.Footer>
</Form>;
-ReportForm.propTypes = {
+SeedForm.propTypes = {
errors: PropTypes.shape({
seed: PropTypes.string,
}),
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 }) => ({
validationSchema: yup.object().shape({
seed: yup.string().required().url(),
}),
-})(withTranslation()(ReportForm));
+})(withTranslation()(SeedForm));
--- /dev/null
+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 <>
+ <EditStreamLinkDialog
+ onHide={() => setShowDialog(false)}
+ show={showDialog}
+ user={user}
+ />
+ <Button
+ onClick={() => setShowDialog(true)}
+ title={i18n.t('button.edit')}
+ variant="outline-secondary"
+ >
+ <Icon.EDIT title="" />
+ </Button>
+ </>;
+ }
+ return null;
+};
+
+EditStreamLinkButton.propTypes = {
+ authUser: PropTypes.shape({
+ }),
+ user: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(withUser(EditStreamLinkButton, 'authUser'));
--- /dev/null
+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,
+}) =>
+<Modal className="edit-stream-link-dialog" onHide={onHide} show={show}>
+ <Modal.Header closeButton>
+ <Modal.Title>
+ {i18n.t('users.editStreamLink')}
+ </Modal.Title>
+ </Modal.Header>
+ <EditStreamLinkForm
+ onCancel={onHide}
+ user={user}
+ />
+</Modal>;
+
+EditStreamLinkDialog.propTypes = {
+ onHide: PropTypes.func,
+ show: PropTypes.bool,
+ user: PropTypes.shape({
+ }),
+};
+
+export default withTranslation()(EditStreamLinkDialog);
--- /dev/null
+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,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+ <Modal.Body>
+ <Row>
+ <Form.Group as={Col} controlId="user.stream_link">
+ <Form.Label>{i18n.t('users.streamLink')}</Form.Label>
+ <Form.Control
+ isInvalid={!!(touched.stream_link && errors.stream_link)}
+ name="stream_link"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ placeholder="https://www.twitch.tv/fgfm"
+ type="text"
+ value={values.stream_link || ''}
+ />
+ {touched.stream_link && errors.stream_link ?
+ <Form.Control.Feedback type="invalid">
+ {i18n.t(errors.stream_link)}
+ </Form.Control.Feedback>
+ : null}
+ </Form.Group>
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ {onCancel ?
+ <Button onClick={onCancel} variant="secondary">
+ {i18n.t('button.cancel')}
+ </Button>
+ : null}
+ <Button type="submit" variant="primary">
+ {i18n.t('button.save')}
+ </Button>
+ </Modal.Footer>
+</Form>;
+
+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));
--- /dev/null
+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 }) => <Container>
+ <h1>{user.username}</h1>
+ <Row>
+ <Col md={6}>
+ <h2>{i18n.t('users.discordTag')}</h2>
+ <Box discriminator user={user} />
+ </Col>
+ <Col md={6}>
+ <h2>{i18n.t('users.streamLink')}</h2>
+ <p>
+ {user.stream_link ?
+ <Button
+ href={user.stream_link}
+ target="_blank"
+ variant="outline-twitch"
+ >
+ <Icon.STREAM />
+ {' '}
+ {user.stream_link}
+ </Button>
+ :
+ i18n.t('users.noStream')
+ }
+ {' '}
+ <EditStreamLinkButton user={user} />
+ </p>
+ </Col>
+ </Row>
+</Container>;
+
+Profile.propTypes = {
+ user: PropTypes.shape({
+ stream_link: PropTypes.string,
+ username: PropTypes.string,
+ }),
+};
+
+export default withTranslation()(Profile);
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;
compareResult,
compareUsername,
findResult,
+ patchUser,
sortByResult,
};
};
};
+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;
compareScore,
findParticipant,
patchResult,
+ patchRound,
+ patchUser,
sortParticipants,
};
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);
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: {
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: {
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: {
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: {
margin-bottom: 2rem;
}
+.text-discord {
+ color: $discord;
+}
+.text-twitch {
+ color: $twitch;
+}
+
.text-gold {
color: $gold;
}
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');