From 5a575dc29f3af10f1d8e142ff9e1c6ccdfa3b075 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 24 Feb 2023 16:31:07 +0100 Subject: [PATCH] crew management --- app/Http/Controllers/EpisodeController.php | 154 +++++++++++++++++- app/Models/ChannelCrew.php | 4 + app/Models/Episode.php | 4 +- app/Models/EpisodeCrew.php | 8 + app/Models/EpisodePlayer.php | 4 + app/Models/Restream.php | 18 ++ app/Policies/ChannelPolicy.php | 14 ++ app/Policies/EpisodePolicy.php | 14 ++ ...023_02_24_081107_restream_registration.php | 34 ++++ resources/js/components/common/Icon.js | 1 + .../js/components/episodes/ApplyDialog.js | 41 +++++ resources/js/components/episodes/ApplyForm.js | 119 ++++++++++++++ resources/js/components/episodes/Crew.js | 56 +++++-- .../js/components/episodes/CrewManagement.js | 119 ++++++++++++++ .../js/components/episodes/DialogEpisode.js | 36 ++++ resources/js/components/episodes/Item.js | 11 +- resources/js/components/episodes/List.js | 4 +- .../js/components/episodes/RestreamAddForm.js | 63 +++++-- .../js/components/episodes/RestreamDialog.js | 6 + .../components/episodes/RestreamEditForm.js | 72 +++++++- resources/js/components/pages/Schedule.js | 114 ++++++++++++- resources/js/helpers/Episode.js | 15 ++ resources/js/helpers/permissions.js | 31 ++++ resources/js/i18n/de.js | 23 +++ resources/js/i18n/en.js | 23 +++ resources/sass/bootstrap.scss | 1 + resources/sass/episodes.scss | 32 ++-- routes/api.php | 3 + 28 files changed, 966 insertions(+), 58 deletions(-) create mode 100644 app/Models/Restream.php create mode 100644 database/migrations/2023_02_24_081107_restream_registration.php create mode 100644 resources/js/components/episodes/ApplyDialog.js create mode 100644 resources/js/components/episodes/ApplyForm.js create mode 100644 resources/js/components/episodes/CrewManagement.js create mode 100644 resources/js/components/episodes/DialogEpisode.js create mode 100644 resources/js/helpers/Episode.js diff --git a/app/Http/Controllers/EpisodeController.php b/app/Http/Controllers/EpisodeController.php index 1a407a1..9892cee 100644 --- a/app/Http/Controllers/EpisodeController.php +++ b/app/Http/Controllers/EpisodeController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers; use App\Models\Channel; use App\Models\Episode; +use App\Models\EpisodeCrew; +use App\Models\User; use Carbon\Carbon; use Illuminate\Http\Request; @@ -13,6 +15,8 @@ class EpisodeController extends Controller public function addRestream(Request $request, Episode $episode) { $this->authorize('addRestream', $episode); $validatedData = $request->validate([ + 'accept_comms' => 'boolean', + 'accept_tracker' => 'boolean', 'channel_id' => 'numeric|exists:App\Models\Channel,id', ]); @@ -25,7 +29,146 @@ class EpisodeController extends Controller } } - $episode->channels()->attach($channel); + $validatedProps = $request->validate([ + 'accept_comms' => 'boolean', + 'accept_tracker' => 'boolean', + ]); + + $episode->channels()->attach($channel, $validatedProps); + + return $episode->load('channels')->toJson(); + } + + public function crewManage(Request $request, Episode $episode) { + $this->authorize('editRestream', $episode); + $validatedData = $request->validate([ + 'add' => 'numeric|exists:App\Models\User,id', + 'channel_id' => 'numeric|exists:App\Models\Channel,id', + 'confirm' => 'nullable|numeric|exists:App\Models\EpisodeCrew,id', + 'remove' => 'numeric|exists:App\Models\EpisodeCrew,id', + 'role' => 'string|in:commentary,setup,tracking', + 'unconfirm' => 'nullable|numeric|exists:App\Models\EpisodeCrew,id', + ]); + + $channel = Channel::find($validatedData['channel_id']); + $this->authorize('editRestream', $channel); + + if (isset($validatedData['add'])) { + $crew = $episode->crew() + ->where('user_id', '=', $validatedData['add']) + ->where('role', '=', $validatedData['role']) + ->first(); + if (!$crew) { + $add_user = User::findOrFail($validatedData['add']); + $crew = new EpisodeCrew(); + $crew->channel()->associate($channel); + $crew->episode()->associate($episode); + $crew->user()->associate($add_user); + $crew->role = $validatedData['role']; + } + $crew->confirmed = true; + $crew->save(); + } + + if (isset($validatedData['confirm'])) { + $crew = EpisodeCrew::find($validatedData['confirm']); + $crew->confirmed = true; + $crew->save(); + } + + if (isset($validatedData['remove'])) { + $crew = EpisodeCrew::find($validatedData['remove']); + if ($crew) { + $crew->delete(); + } + } + + if (isset($validatedData['unconfirm'])) { + $crew = EpisodeCrew::find($validatedData['unconfirm']); + $crew->confirmed = false; + $crew->save(); + } + + $user = $request->user(); + if ($user->isPrivileged()) { + return $episode->load(['crew', 'crew.user'])->toJson(); + } else { + return $episode->load([ + 'crew' => function ($query) use ($user) { + $query->where('confirmed', true); + $query->orWhere('user_id', '=', $user->id); + $query->orWhereIn('channel_id', $user->channel_crews->pluck('channel_id')); + }, + 'crew.user', + ])->toJson(); + } + } + + public function crewSignup(Request $request, Episode $episode) { + if (!$request->user()) { + throw new \Exception('requires user to sign up'); + } + $validatedData = $request->validate([ + 'as' => 'string|in:commentary,tracking', + 'channel_id' => 'numeric|exists:App\Models\Channel,id', + ]); + + $channel = $episode->channels()->find($validatedData['channel_id']); + if (!$channel) { + throw new \Exception('channel not found'); + } + + $as = $validatedData['as']; + if ($as == 'commentary' && !$channel->pivot->accept_comms) { + throw new \Exception('channel not looking for commentary'); + } + if ($as == 'tracking' && !$channel->pivot->accept_tracker) { + throw new \Exception('channel not looking for trackers'); + } + + $user = $request->user(); + + foreach ($episode->crew as $crew) { + if ($crew->user_id == $user->id && $crew->role == $as) { + throw new \Exception('already signed up'); + } + } + + $episode->crew()->create([ + 'channel_id' => $channel->id, + 'role' => $as, + 'user_id' => $user->id, + ]); + + if ($user->isPrivileged()) { + return $episode->load(['crew', 'crew.user'])->toJson(); + } else { + return $episode->load([ + 'crew' => function ($query) use ($user) { + $query->where('confirmed', true); + $query->orWhere('user_id', '=', $user->id); + $query->orWhereIn('channel_id', $user->channel_crews->pluck('channel_id')); + }, + 'crew.user', + ])->toJson(); + } + } + + public function editRestream(Request $request, Episode $episode) { + $this->authorize('editRestream', $episode); + $validatedData = $request->validate([ + 'channel_id' => 'numeric|exists:App\Models\Channel,id', + ]); + + $channel = Channel::find($validatedData['channel_id']); + $this->authorize('editRestream', $channel); + + $validatedChanges = $request->validate([ + 'accept_comms' => 'boolean', + 'accept_tracker' => 'boolean', + ]); + + $episode->channels()->updateExistingPivot($channel, $validatedChanges); return $episode->load('channels')->toJson(); } @@ -67,6 +210,15 @@ class EpisodeController extends Controller } if ($request->user() && $request->user()->isPrivileged()) { $episodes = $episodes->with(['crew', 'crew.user']); + } else if ($request->user()) { + $episodes = $episodes->with([ + 'crew' => function ($query) use ($request) { + $query->where('confirmed', true); + $query->orWhere('user_id', '=', $request->user()->id); + $query->orWhereIn('channel_id', $request->user()->channel_crews->pluck('channel_id')); + }, + 'crew.user', + ]); } else { $episodes = $episodes->with([ 'crew' => function ($query) { diff --git a/app/Models/ChannelCrew.php b/app/Models/ChannelCrew.php index 23c758e..0f78eb0 100644 --- a/app/Models/ChannelCrew.php +++ b/app/Models/ChannelCrew.php @@ -17,4 +17,8 @@ class ChannelCrew extends Model return $this->belongsTo(User::class); } + protected $casts = [ + 'user_id' => 'string', + ]; + } diff --git a/app/Models/Episode.php b/app/Models/Episode.php index 15af89c..2084a32 100644 --- a/app/Models/Episode.php +++ b/app/Models/Episode.php @@ -11,7 +11,9 @@ class Episode extends Model use HasFactory; public function channels() { - return $this->belongsToMany(Channel::class); + return $this->belongsToMany(Channel::class) + ->using(Restream::class) + ->withPivot('accept_comms', 'accept_tracker'); } public function crew() { diff --git a/app/Models/EpisodeCrew.php b/app/Models/EpisodeCrew.php index 1ec042f..d06276e 100644 --- a/app/Models/EpisodeCrew.php +++ b/app/Models/EpisodeCrew.php @@ -23,6 +23,14 @@ class EpisodeCrew extends Model protected $casts = [ 'confirmed' => 'boolean', + 'user_id' => 'string', + ]; + + protected $fillable = [ + 'channel_id', + 'episode_id', + 'role', + 'user_id', ]; protected $hidden = [ diff --git a/app/Models/EpisodePlayer.php b/app/Models/EpisodePlayer.php index 9099190..1125701 100644 --- a/app/Models/EpisodePlayer.php +++ b/app/Models/EpisodePlayer.php @@ -17,6 +17,10 @@ class EpisodePlayer extends Model return $this->belongsTo(User::class); } + protected $casts = [ + 'user_id' => 'string', + ]; + protected $hidden = [ 'created_at', 'ext_id', diff --git a/app/Models/Restream.php b/app/Models/Restream.php new file mode 100644 index 0000000..8be7e2d --- /dev/null +++ b/app/Models/Restream.php @@ -0,0 +1,18 @@ + 'boolean', + 'accept_tracker' => 'boolean', + ]; + +} + +?> diff --git a/app/Policies/ChannelPolicy.php b/app/Policies/ChannelPolicy.php index 03abdff..3e42c9f 100644 --- a/app/Policies/ChannelPolicy.php +++ b/app/Policies/ChannelPolicy.php @@ -106,6 +106,20 @@ class ChannelPolicy ->count() > 0; } + /** + * Determine whether the user can edit restreams on the channel. + * + * @param \App\Models\User $user + * @param \App\Models\Channel $channel + * @return \Illuminate\Auth\Access\Response|bool + */ + public function editRestream(User $user, Channel $channel) { + return $user->channel_crews() + ->where('role', '=', 'admin') + ->where('channel_id', '=', $channel->id) + ->count() > 0; + } + /** * Determine whether the user can remove episodes from the channel. * diff --git a/app/Policies/EpisodePolicy.php b/app/Policies/EpisodePolicy.php index 6be9ad0..1957bbe 100644 --- a/app/Policies/EpisodePolicy.php +++ b/app/Policies/EpisodePolicy.php @@ -106,6 +106,20 @@ class EpisodePolicy ->count() > 0; } + /** + * Determine whether the user can edit restreams from the episode. + * + * @param \App\Models\User $user + * @param \App\Models\Episode $episode + * @return \Illuminate\Auth\Access\Response|bool + */ + public function editRestream(User $user, Episode $episode) { + return $user->channel_crews() + ->where('role', '=', 'admin') + ->whereIn('channel_id', $episode->channels->pluck('id')) + ->count() > 0; + } + /** * Determine whether the user can remove restreams from the episode. * diff --git a/database/migrations/2023_02_24_081107_restream_registration.php b/database/migrations/2023_02_24_081107_restream_registration.php new file mode 100644 index 0000000..fe86f8a --- /dev/null +++ b/database/migrations/2023_02_24_081107_restream_registration.php @@ -0,0 +1,34 @@ +boolean('accept_comms')->default(false); + $table->boolean('accept_tracker')->default(false); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('channel_episode', function(Blueprint $table) { + $table->dropColumn('accept_comms'); + $table->dropColumn('accept_tracker'); + }); + } +}; diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 384362c..00a65a4 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -64,6 +64,7 @@ Icon.APPLY = makePreset('ApplyIcon', 'right-to-bracket'); Icon.APPLICATIONS = makePreset('ApplicationsIcon', 'person-running'); Icon.CHART = makePreset('ChartIcon', 'chart-line'); Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs'); +Icon.DELETE = makePreset('DeleteIcon', 'user-xmark'); Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']); Icon.EDIT = makePreset('EditIcon', 'edit'); Icon.FINISHED = makePreset('FinishedIcon', 'square-check'); diff --git a/resources/js/components/episodes/ApplyDialog.js b/resources/js/components/episodes/ApplyDialog.js new file mode 100644 index 0000000..5f0422e --- /dev/null +++ b/resources/js/components/episodes/ApplyDialog.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import ApplyForm from './ApplyForm'; + +const ApplyDialog = ({ + as, + episode, + onHide, + onSubmit, + show, +}) => { + const { t } = useTranslation(); + + return + + + {t('episodes.applyDialog.title')} + + + + ; +}; + +ApplyDialog.propTypes = { + as: PropTypes.string, + episode: PropTypes.shape({ + }), + onHide: PropTypes.func, + onSubmit: PropTypes.func, + show: PropTypes.bool, +}; + +export default ApplyDialog; diff --git a/resources/js/components/episodes/ApplyForm.js b/resources/js/components/episodes/ApplyForm.js new file mode 100644 index 0000000..65befb7 --- /dev/null +++ b/resources/js/components/episodes/ApplyForm.js @@ -0,0 +1,119 @@ +import { withFormik } from 'formik'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import DialogEpisode from './DialogEpisode'; +import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik'; +import { applicableChannels } from '../../helpers/permissions'; +import { withUser } from '../../helpers/UserContext'; + +const ApplyForm = ({ + as, + episode, + errors, + handleBlur, + handleChange, + handleSubmit, + onCancel, + touched, + user, + values, +}) => { + const { t } = useTranslation(); + + const available_channels = React.useMemo(() => { + return applicableChannels(user, episode, as); + }, [as, episode, user]); + + return
+ + + + {t('episodes.applyDialog.signUpAs')} + + + + {t('episodes.channel')} + + + {available_channels.map(c => + + )} + + {touched.channel_id && errors.channel_id ? + + {t(errors.channel_id)} + + : null} + + + + {onCancel ? + + : null} + + +
; +}; + +ApplyForm.propTypes = { + episode: PropTypes.shape({ + }), + errors: PropTypes.shape({ + channel_id: PropTypes.string, + }), + handleBlur: PropTypes.func, + handleChange: PropTypes.func, + handleSubmit: PropTypes.func, + onCancel: PropTypes.func, + touched: PropTypes.shape({ + channel_id: PropTypes.bool, + }), + user: PropTypes.shape({ + }), + values: PropTypes.shape({ + channel_id: PropTypes.number, + }), +}; + +export default withUser(withFormik({ + displayName: 'ApplyForm', + enableReinitialize: true, + handleSubmit: async (values, actions) => { + const { setErrors } = actions; + const { onSubmit } = actions.props; + try { + await onSubmit(values); + } catch (e) { + if (e.response && e.response.data && e.response.data.errors) { + setErrors(laravelErrorsToFormik(e.response.data.errors)); + } + } + }, + mapPropsToValues: ({ as, episode, user }) => { + const channels = applicableChannels(user, episode, as); + return { + as, + channel_id: channels.length ? channels[0].id : 0, + episode_id: episode ? episode.id : 0, + }; + }, +})(ApplyForm)); diff --git a/resources/js/components/episodes/Crew.js b/resources/js/components/episodes/Crew.js index 0a037ee..76a38a6 100644 --- a/resources/js/components/episodes/Crew.js +++ b/resources/js/components/episodes/Crew.js @@ -1,27 +1,29 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Col, Row } from 'react-bootstrap'; +import { Button, Col, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; import CrewMember from './CrewMember'; import Icon from '../common/Icon'; import { compareCrew } from '../../helpers/Crew'; +import { canApplyForEpisode } from '../../helpers/permissions'; +import { withUser } from '../../helpers/UserContext'; -const Crew = ({ crew }) => { +const Crew = ({ episode, onApply, user }) => { const { t } = useTranslation(); const commentators = React.useMemo(() => - crew.filter(c => c.role === 'commentary').sort(compareCrew) - , [crew]); + episode.crew.filter(c => c.role === 'commentary').sort(compareCrew) + , [episode]); const trackers = React.useMemo(() => - crew.filter(c => c.role === 'tracking').sort(compareCrew) - , [crew]); + episode.crew.filter(c => c.role === 'tracking').sort(compareCrew) + , [episode]); const techies = React.useMemo(() => - crew.filter(c => c.role === 'setup').sort(compareCrew) - , [crew]); + episode.crew.filter(c => c.role === 'setup').sort(compareCrew) + , [episode]); return - {commentators.length ? + {commentators.length || canApplyForEpisode(user, episode, 'commentary') ?
@@ -30,9 +32,19 @@ const Crew = ({ crew }) => { {commentators.map(c => )} + {onApply && canApplyForEpisode(user, episode, 'commentary') ? +
+ +
+ : null} : null} - {trackers.length ? + {trackers.length || canApplyForEpisode(user, episode, 'tracking') ?
@@ -41,6 +53,16 @@ const Crew = ({ crew }) => { {trackers.map(c => )} + {onApply && canApplyForEpisode(user, episode, 'tracking') ? +
+ +
+ : null} : null} {techies.length ? @@ -58,8 +80,16 @@ const Crew = ({ crew }) => { }; Crew.propTypes = { - crew: PropTypes.arrayOf(PropTypes.shape({ - })), + episode: PropTypes.shape({ + channels: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number, + })), + crew: PropTypes.arrayOf(PropTypes.shape({ + })), + }), + onApply: PropTypes.func, + user: PropTypes.shape({ + }), }; -export default Crew; +export default withUser(Crew); diff --git a/resources/js/components/episodes/CrewManagement.js b/resources/js/components/episodes/CrewManagement.js new file mode 100644 index 0000000..7c5d48e --- /dev/null +++ b/resources/js/components/episodes/CrewManagement.js @@ -0,0 +1,119 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import CrewMember from './CrewMember'; +import Icon from '../common/Icon'; +import UserSelect from '../common/UserSelect'; + +const CrewManagement = ({ + channel, + episode, + manageCrew, + role, +}) => { + const { t } = useTranslation(); + + const crews = React.useMemo(() => + (episode.crew || []) + .filter(c => c.channel_id === channel.id && c.role === role) + , [channel, episode, role]); + + const addCrew = React.useCallback(user_id => { + manageCrew({ + add: user_id, + channel_id: channel.id, + episode_id: episode.id, + role, + }); + }, [channel, episode, manageCrew, role]); + + const confirmCrew = React.useCallback(crew => { + manageCrew({ + channel_id: channel.id, + confirm: crew.id, + episode_id: episode.id, + role, + }); + }, [channel, episode, manageCrew, role]); + + const removeCrew = React.useCallback(crew => { + manageCrew({ + channel_id: channel.id, + episode_id: episode.id, + remove: crew.id, + role, + }); + }, [channel, episode, manageCrew, role]); + + const unconfirmCrew = React.useCallback(crew => { + manageCrew({ + channel_id: channel.id, + episode_id: episode.id, + role, + unconfirm: crew.id, + }); + }, [channel, episode, manageCrew, role]); + + return
+
{t(`crew.roles.${role}`)}
+ {crews.map(crew => +
+ +
+ {crew.confirmed ? + + : null} + {!crew.confirmed ? + + : null} + +
+
+ )} + + {t('episodes.restreamDialog.addUser')} + addCrew(e.target.value)} + value="" + /> + +
; +}; + +CrewManagement.propTypes = { + channel: PropTypes.shape({ + id: PropTypes.number, + }), + episode: PropTypes.shape({ + crew: PropTypes.arrayOf(PropTypes.shape({ + channel_id: PropTypes.number, + role: PropTypes.string, + })), + id: PropTypes.number, + }), + manageCrew: PropTypes.func, + role: PropTypes.string, +}; + +export default CrewManagement; diff --git a/resources/js/components/episodes/DialogEpisode.js b/resources/js/components/episodes/DialogEpisode.js new file mode 100644 index 0000000..c60b052 --- /dev/null +++ b/resources/js/components/episodes/DialogEpisode.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { getName } from '../../helpers/Crew'; + +const DialogEpisode = ({ episode }) => { + const { t } = useTranslation(); + + if (!episode) return null; + + return <> +
+ {episode.event.title} +
+
+ {t('episodes.startTime', { date: new Date(episode.start) })} +
+
+ {episode.players.map(p => getName(p)).join(', ')} +
+ ; +}; + +DialogEpisode.propTypes = { + episode: PropTypes.shape({ + event: PropTypes.shape({ + title: PropTypes.string, + }), + players: PropTypes.arrayOf(PropTypes.shape({ + })), + start: PropTypes.string, + }), +}; + +export default DialogEpisode; diff --git a/resources/js/components/episodes/Item.js b/resources/js/components/episodes/Item.js index 19760ee..c911bc3 100644 --- a/resources/js/components/episodes/Item.js +++ b/resources/js/components/episodes/Item.js @@ -9,7 +9,7 @@ import Crew from './Crew'; import MultiLink from './MultiLink'; import Players from './Players'; import Icon from '../common/Icon'; -import { canRestreamEpisode } from '../../helpers/permissions'; +import { canApplyForEpisode, canRestreamEpisode } from '../../helpers/permissions'; import { withUser } from '../../helpers/UserContext'; const isActive = episode => { @@ -20,7 +20,7 @@ const isActive = episode => { return start.isBefore(now) && end.isAfter(now); }; -const Item = ({ episode, onAddRestream, onEditRestream, user }) => { +const Item = ({ episode, onAddRestream, onApply, onEditRestream, user }) => { const { t } = useTranslation(); const classNames = [ @@ -93,8 +93,10 @@ const Item = ({ episode, onAddRestream, onEditRestream, user }) => { {hasPlayers ? : null} - {episode.crew && episode.crew.length ? - + {(episode.crew && episode.crew.length) + || canApplyForEpisode(user, episode, 'commentary') + || canApplyForEpisode(user, episode, 'tracking') ? + : null}
; @@ -116,6 +118,7 @@ Item.propTypes = { title: PropTypes.string, }), onAddRestream: PropTypes.func, + onApply: PropTypes.func, onEditRestream: PropTypes.func, user: PropTypes.shape({ }), diff --git a/resources/js/components/episodes/List.js b/resources/js/components/episodes/List.js index 6f46e75..a2a3b29 100644 --- a/resources/js/components/episodes/List.js +++ b/resources/js/components/episodes/List.js @@ -4,7 +4,7 @@ import React from 'react'; import Item from './Item'; -const List = ({ episodes, onAddRestream, onEditRestream }) => { +const List = ({ episodes, onAddRestream, onApply, onEditRestream }) => { const grouped = React.useMemo(() => episodes.reduce((groups, episode) => { const day = moment(episode.start).format('YYYY-MM-DD'); return { @@ -25,6 +25,7 @@ const List = ({ episodes, onAddRestream, onEditRestream }) => { @@ -38,6 +39,7 @@ List.propTypes = { start: PropTypes.string, })), onAddRestream: PropTypes.func, + onApply: PropTypes.func, onEditRestream: PropTypes.func, }; diff --git a/resources/js/components/episodes/RestreamAddForm.js b/resources/js/components/episodes/RestreamAddForm.js index 747b47e..7cbb87d 100644 --- a/resources/js/components/episodes/RestreamAddForm.js +++ b/resources/js/components/episodes/RestreamAddForm.js @@ -1,10 +1,11 @@ import { withFormik } from 'formik'; import PropTypes from 'prop-types'; import React from 'react'; -import { Button, Form, Modal } from 'react-bootstrap'; +import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; -import { getName } from '../../helpers/Crew'; +import DialogEpisode from './DialogEpisode'; +import ToggleSwitch from '../common/ToggleSwitch'; import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik'; import { withUser } from '../../helpers/UserContext'; @@ -23,17 +24,7 @@ const RestreamAddForm = ({ return
- {episode ? <> -
- {episode.event.title} -
-
- {t('episodes.startTime', { date: new Date(episode.start) })} -
-
- {episode.players.map(p => getName(p)).join(', ')} -
- : null} + {t('episodes.channel')} : null} + + + + {t('episodes.restreamDialog.acceptComms')} + + + {touched.accept_comms && errors.accept_comms ? + + {t(errors.accept_comms)} + + : null} + + + + {t('episodes.restreamDialog.acceptTracker')} + + + {touched.accept_tracker && errors.accept_tracker ? + + {t(errors.accept_tracker)} + + : null} + +
{onCancel ? @@ -80,6 +109,8 @@ RestreamAddForm.propTypes = { start: PropTypes.string, }), errors: PropTypes.shape({ + accept_comms: PropTypes.string, + accept_tracker: PropTypes.string, channel_id: PropTypes.string, }), handleBlur: PropTypes.func, @@ -87,6 +118,8 @@ RestreamAddForm.propTypes = { handleSubmit: PropTypes.func, onCancel: PropTypes.func, touched: PropTypes.shape({ + accept_comms: PropTypes.bool, + accept_tracker: PropTypes.bool, channel_id: PropTypes.bool, }), user: PropTypes.shape({ @@ -94,6 +127,8 @@ RestreamAddForm.propTypes = { })), }), values: PropTypes.shape({ + accept_comms: PropTypes.bool, + accept_tracker: PropTypes.bool, channel_id: PropTypes.number, }), }; @@ -113,6 +148,8 @@ export default withUser(withFormik({ } }, mapPropsToValues: ({ episode, user }) => ({ + accept_comms: false, + accept_tracker: false, channel_id: user && user.channel_crews && user.channel_crews.length ? user.channel_crews[0].channel_id : 0, episode_id: episode ? episode.id : 0, diff --git a/resources/js/components/episodes/RestreamDialog.js b/resources/js/components/episodes/RestreamDialog.js index 340b3c3..2b7454b 100644 --- a/resources/js/components/episodes/RestreamDialog.js +++ b/resources/js/components/episodes/RestreamDialog.js @@ -8,7 +8,9 @@ import RestreamEditForm from './RestreamEditForm'; const RestreamDialog = ({ channel, + editRestream, episode, + manageCrew, onHide, onRemoveRestream, onSubmit, @@ -25,7 +27,9 @@ const RestreamDialog = ({ {channel ? @@ -42,8 +46,10 @@ const RestreamDialog = ({ RestreamDialog.propTypes = { channel: PropTypes.shape({ }), + editRestream: PropTypes.func, episode: PropTypes.shape({ }), + manageCrew: PropTypes.func, onHide: PropTypes.func, onRemoveRestream: PropTypes.func, onSubmit: PropTypes.func, diff --git a/resources/js/components/episodes/RestreamEditForm.js b/resources/js/components/episodes/RestreamEditForm.js index e4cbe13..84a3ea2 100644 --- a/resources/js/components/episodes/RestreamEditForm.js +++ b/resources/js/components/episodes/RestreamEditForm.js @@ -1,18 +1,30 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Button, Modal } from 'react-bootstrap'; +import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import CrewManagement from './CrewManagement'; +import ToggleSwitch from '../common/ToggleSwitch'; import { getName } from '../../helpers/Crew'; const RestreamEditForm = ({ channel, + editRestream, episode, + manageCrew, onCancel, onRemoveRestream, }) => { const { t } = useTranslation(); + const acceptToggle = React.useCallback(e => { + editRestream({ + channel_id: channel.id, + episode_id: episode.id, + [e.target.name]: e.target.value, + }); + }, [channel, editRestream, episode]); + return <> {channel ? @@ -31,6 +43,52 @@ const RestreamEditForm = ({ {episode.players.map(p => getName(p)).join(', ')} : null} + {channel && episode && editRestream ? + + + + {t('episodes.restreamDialog.acceptComms')} + + + + + + {t('episodes.restreamDialog.acceptTracker')} + + + + + : null} + {channel && episode && manageCrew ? <> + + + + : null} {onRemoveRestream ? @@ -49,16 +107,28 @@ const RestreamEditForm = ({ RestreamEditForm.propTypes = { channel: PropTypes.shape({ + id: PropTypes.number, + pivot: PropTypes.shape({ + accept_comms: PropTypes.bool, + accept_tracker: PropTypes.bool, + }), title: PropTypes.string, }), + editRestream: PropTypes.func, episode: PropTypes.shape({ + crew: PropTypes.arrayOf(PropTypes.shape({ + channel_id: PropTypes.number, + role: PropTypes.string, + })), event: PropTypes.shape({ title: PropTypes.string, }), + id: PropTypes.number, players: PropTypes.arrayOf(PropTypes.shape({ })), start: PropTypes.string, }), + manageCrew: PropTypes.func, onCancel: PropTypes.func, onRemoveRestream: PropTypes.func, }; diff --git a/resources/js/components/pages/Schedule.js b/resources/js/components/pages/Schedule.js index 221fa98..3d0a140 100644 --- a/resources/js/components/pages/Schedule.js +++ b/resources/js/components/pages/Schedule.js @@ -9,6 +9,7 @@ import toastr from 'toastr'; import CanonicalLinks from '../common/CanonicalLinks'; import ErrorBoundary from '../common/ErrorBoundary'; +import ApplyDialog from '../episodes/ApplyDialog'; import Filter from '../episodes/Filter'; import List from '../episodes/List'; import RestreamDialog from '../episodes/RestreamDialog'; @@ -16,11 +17,13 @@ import { withUser } from '../../helpers/UserContext'; const Schedule = ({ user }) => { const [ahead] = React.useState(14); + const [applyAs, setApplyAs] = React.useState('commentary'); const [behind] = React.useState(0); const [episodes, setEpisodes] = React.useState([]); const [filter, setFilter] = React.useState({}); const [restreamChannel, setRestreamChannel] = React.useState(null); const [restreamEpisode, setRestreamEpisode] = React.useState(null); + const [showApplyDialog, setShowApplyDialog] = React.useState(false); const [showRestreamDialog, setShowRestreamDialog] = React.useState(false); const { t } = useTranslation(); @@ -107,12 +110,95 @@ const Schedule = ({ user }) => { setShowRestreamDialog(true); }, []); + const editRestream = React.useCallback(async values => { + try { + const response = await axios.post( + `/api/episodes/${values.episode_id}/edit-restream`, values); + const newEpisode = response.data; + setEpisodes(episodes => episodes.map(episode => + episode.id === newEpisode.id ? { + ...episode, + ...newEpisode, + } : episode + )); + setRestreamEpisode(episode => ({ + ...episode, + ...newEpisode, + })); + const newChannel = newEpisode.channels.find(c => c.id === values.channel_id); + setRestreamChannel(channel => ({ + ...channel, + ...newChannel, + })); + toastr.success(t('episodes.restreamDialog.editSuccess')); + } catch (e) { + toastr.error(t('episodes.restreamDialog.editError')); + } + }, []); + + const manageCrew = React.useCallback(async values => { + try { + const response = await axios.post( + `/api/episodes/${values.episode_id}/crew-manage`, values); + const newEpisode = response.data; + setEpisodes(episodes => episodes.map(episode => + episode.id === newEpisode.id ? { + ...episode, + ...newEpisode, + } : episode + )); + setRestreamEpisode(episode => ({ + ...episode, + ...newEpisode, + })); + const newChannel = newEpisode.channels.find(c => c.id === values.channel_id); + setRestreamChannel(channel => ({ + ...channel, + ...newChannel, + })); + toastr.success(t('episodes.restreamDialog.crewSuccess')); + } catch (e) { + toastr.error(t('episodes.restreamDialog.crewError')); + } + }, []); + const onHideRestreamDialog = React.useCallback(() => { setShowRestreamDialog(false); setRestreamChannel(null); setRestreamEpisode(null); }, []); + const onApply = React.useCallback((episode, as) => { + setShowApplyDialog(true); + setRestreamEpisode(episode); + setApplyAs(as); + }, []); + + const onSubmitApplyDialog = React.useCallback(async values => { + try { + const response = await axios.post( + `/api/episodes/${values.episode_id}/crew-signup`, values); + const newEpisode = response.data; + setEpisodes(episodes => episodes.map(episode => + episode.id === newEpisode.id ? { + ...episode, + ...newEpisode, + } : episode + )); + toastr.success(t('episodes.applyDialog.applySuccess')); + } catch (e) { + toastr.error(t('episodes.applyDialog.applyError')); + throw e; + } + setRestreamEpisode(null); + setShowApplyDialog(false); + }, []); + + const onHideApplyDialog = React.useCallback(() => { + setShowApplyDialog(false); + setRestreamEpisode(null); + }, []); + React.useEffect(() => { const controller = new AbortController(); fetchEpisodes(controller, ahead, behind, filter); @@ -142,6 +228,7 @@ const Schedule = ({ user }) => { : @@ -150,14 +237,25 @@ const Schedule = ({ user }) => { } - + {user ? <> + + + : null} ; }; diff --git a/resources/js/helpers/Episode.js b/resources/js/helpers/Episode.js new file mode 100644 index 0000000..675fb36 --- /dev/null +++ b/resources/js/helpers/Episode.js @@ -0,0 +1,15 @@ +export const acceptsComms = episode => { + if (!episode || !episode.channels) return false; + return !!episode.channels.find(c => c.pivot && c.pivot.accept_comms); +}; + +export const acceptsTrackers = episode => { + if (!episode || !episode.channels) return false; + return !!episode.channels.find(c => c.pivot && c.pivot.accept_tracker); +}; + +export const acceptsCrew = episode => { + if (!episode || !episode.channels) return false; + return !!episode.channels.find(c => + c.pivot && (c.pivot.accept_comms || c.pivot.accept_tracker)); +}; diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 835af24..54bf905 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -1,6 +1,7 @@ /// NOTE: These permissions are for UI cosmetics only! /// They should be in sync with the backend Policies. +import * as Episode from './Episode'; import Round from './Round'; export const isAdmin = user => user && user.role === 'admin'; @@ -15,6 +16,16 @@ export const isChannelAdmin = (user, channel) => // Episodes +export const isCommentator = (user, episode) => { + if (!user || !episode || !episode.crew) return false; + return !!episode.crew.find(c => c.role === "commentary" && c.user_id === user.id); +}; + +export const isTracker = (user, episode) => { + if (!user || !episode || !episode.crew) return false; + return !!episode.crew.find(c => c.role === "tracking" && c.user_id === user.id); +}; + export const episodeHasChannel = (episode, channel) => episode && channel && episode.channels && episode.channels.find(c => c.id === channel.id); @@ -24,6 +35,26 @@ export const mayRestreamEpisodes = user => export const mayEditRestream = (user, episode, channel) => episodeHasChannel(episode, channel) && isChannelAdmin(user, channel); +export const canApplyForEpisode = (user, episode, as) => { + if (!user) return false; + if (as === 'commentary') return Episode.acceptsComms(episode) && !isCommentator(user, episode); + if (as === 'tracking') return Episode.acceptsTrackers(episode) && !isTracker(user, episode); + return false; +}; + +export const applicableChannels = (user, episode, as) => { + if (!user || !episode) return []; + const assigned_channels = (episode.crew || []) + .filter(c => c.user_id === user.id) + .map(c => c.channel_id); + const channels = episode.channels || []; + if (as === 'commentary') return channels.filter(c => + c.pivot && c.pivot.accept_comms && !assigned_channels.includes(c.id)); + if (as === 'tracking') return channels.filter(c => + c.pivot && c.pivot.accept_tracker && !assigned_channels.includes(c.id)); + return []; +}; + export const canRestreamEpisode = (user, episode) => { if (!user || !episode || !mayRestreamEpisodes(user)) return false; const available_channels = user.channel_crews diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index ae1b29e..389c300 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -250,6 +250,7 @@ export default { cancel: 'Abbrechen', chart: 'Diagramm', close: 'Schließen', + confirm: 'Bestätigen', edit: 'Bearbeiten', generate: 'Generieren', help: 'Hilfe', @@ -262,15 +263,37 @@ export default { save: 'Speichern', search: 'Suche', settings: 'Einstellungen', + signUp: 'Anmelden', + unconfirm: 'Zurückziehen', + }, + crew: { + roles: { + commentary: 'Kommentar', + setup: 'Setup', + tracking: 'Tracker', + }, }, episodes: { addRestream: 'Neuer Restream', + applyDialog: { + applyError: 'Fehler bei der Anmeldung', + applySuccess: 'Angemeldet', + signUpAs: 'Anmeldung als', + title: 'Anmeldung', + }, channel: 'Kanal', commentary: 'Kommentar', empty: 'Keine anstehenden Termine.', restreamDialog: { + acceptComms: 'Suche Kommentatoren', + acceptTracker: 'Suche Tracker', addError: 'Fehler beim Hinzufügen', addSuccess: 'Hinzugefügt', + addUser: 'Benutzer hinzufügen', + crewError: 'Fehler beim Aktualisieren', + crewSuccess: 'Aktualisiert', + editError: 'Fehler beim Speichern', + editSuccess: 'Gespeichert', removeError: 'Fehler beim Entfernen', removeSuccess: 'Entfernt', title: 'Restream', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index e499651..ff1905d 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -250,6 +250,7 @@ export default { cancel: 'Cancel', chart: 'Chart', close: 'Close', + confirm: 'Confirm', edit: 'Edit', generate: 'Generate', help: 'Help', @@ -262,15 +263,37 @@ export default { save: 'Save', search: 'Search', settings: 'Settings', + signUp: 'Sign up', + unconfirm: 'Retract', + }, + crew: { + roles: { + commentary: 'Commentary', + setup: 'Setup', + tracking: 'Tracking', + }, }, episodes: { addRestream: 'Add Restream', + applyDialog: { + applyError: 'Error signing up', + applySuccess: 'Application received', + signUpAs: 'Sign up as', + title: 'Application', + }, channel: 'Channel', commentary: 'Commentary', empty: 'No dates coming up.', restreamDialog: { + acceptComms: 'Open commentary application', + acceptTracker: 'Open tracker application', addError: 'Error adding restream', addSuccess: 'Added', + addUser: 'Add user', + crewError: 'Error updating', + crewSuccess: 'Updated', + editError: 'Error saving', + editSuccess: 'Saved', removeError: 'Error removing restream', removeSuccess: 'Removed', title: 'Restream', diff --git a/resources/sass/bootstrap.scss b/resources/sass/bootstrap.scss index e80df7c..bcc81c8 100644 --- a/resources/sass/bootstrap.scss +++ b/resources/sass/bootstrap.scss @@ -11,6 +11,7 @@ $input-color: $body-color; $input-disabled-bg: $gray-700 !default; $input-focus-bg: $input-bg; $input-focus-color: $input-color; +$input-plaintext-color: $input-color; $form-select-bg: $input-bg; $form-select-color: $input-color; $form-select-indicator-color: $input-color; diff --git a/resources/sass/episodes.scss b/resources/sass/episodes.scss index df98cd7..d4248cb 100644 --- a/resources/sass/episodes.scss +++ b/resources/sass/episodes.scss @@ -49,25 +49,25 @@ vertical-align: middle; } } +} - .crew-member { - border: none; +.crew-member { + border: none; - img { - max-height: 2rem; - width: auto; - border-radius: 50%; - margin: 0 0.25rem 0 0; - } - span { - vertical-align: middle; - } + img { + max-height: 2rem; + width: auto; + border-radius: 50%; + margin: 0 0.25rem 0 0; + } + span { + vertical-align: middle; + } - &.unconfirmed { - opacity: 0.25; - &:hover { - opacity: 1; - } + &.unconfirmed { + opacity: 0.25; + &:hover { + opacity: 1; } } } diff --git a/routes/api.php b/routes/api.php index 7980c13..8fdfad4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -38,6 +38,9 @@ Route::get('discord-guilds/{guild_id}/channels', 'App\Http\Controllers\DiscordCh Route::get('episodes', 'App\Http\Controllers\EpisodeController@search'); Route::post('episodes/{episode}/add-restream', 'App\Http\Controllers\EpisodeController@addRestream'); +Route::post('episodes/{episode}/crew-manage', 'App\Http\Controllers\EpisodeController@crewManage'); +Route::post('episodes/{episode}/crew-signup', 'App\Http\Controllers\EpisodeController@crewSignup'); +Route::post('episodes/{episode}/edit-restream', 'App\Http\Controllers\EpisodeController@editRestream'); Route::post('episodes/{episode}/remove-restream', 'App\Http\Controllers\EpisodeController@removeRestream'); Route::get('events', 'App\Http\Controllers\EventController@search'); -- 2.39.2