]> git.localhorst.tv Git - alttp.git/commitdiff
crew management
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 24 Feb 2023 15:31:07 +0000 (16:31 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 24 Feb 2023 15:31:07 +0000 (16:31 +0100)
28 files changed:
app/Http/Controllers/EpisodeController.php
app/Models/ChannelCrew.php
app/Models/Episode.php
app/Models/EpisodeCrew.php
app/Models/EpisodePlayer.php
app/Models/Restream.php [new file with mode: 0644]
app/Policies/ChannelPolicy.php
app/Policies/EpisodePolicy.php
database/migrations/2023_02_24_081107_restream_registration.php [new file with mode: 0644]
resources/js/components/common/Icon.js
resources/js/components/episodes/ApplyDialog.js [new file with mode: 0644]
resources/js/components/episodes/ApplyForm.js [new file with mode: 0644]
resources/js/components/episodes/Crew.js
resources/js/components/episodes/CrewManagement.js [new file with mode: 0644]
resources/js/components/episodes/DialogEpisode.js [new file with mode: 0644]
resources/js/components/episodes/Item.js
resources/js/components/episodes/List.js
resources/js/components/episodes/RestreamAddForm.js
resources/js/components/episodes/RestreamDialog.js
resources/js/components/episodes/RestreamEditForm.js
resources/js/components/pages/Schedule.js
resources/js/helpers/Episode.js [new file with mode: 0644]
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/bootstrap.scss
resources/sass/episodes.scss
routes/api.php

index 1a407a184e97039738e4b3743f888fec05a3f924..9892cee7b2a88f0eca0848a26f78ca15e8fb6426 100644 (file)
@@ -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) {
index 23c758efd64dd2673de3fea387e23436205c948b..0f78eb0a8edc109a0e7f61df2ec6921da0cedd0c 100644 (file)
@@ -17,4 +17,8 @@ class ChannelCrew extends Model
                return $this->belongsTo(User::class);
        }
 
+       protected $casts = [
+               'user_id' => 'string',
+       ];
+
 }
index 15af89cf76f715135d299f00ec902ec27cd9f5f2..2084a32b53f67c53ba0df0720c0f3d0c597973c8 100644 (file)
@@ -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() {
index 1ec042fcc9c91b91487a57224811c366cb3efe04..d06276e537d234d0b7e2e068910881cad8c43a1e 100644 (file)
@@ -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 = [
index 909919097bc9508fae14026e2891c1727ce63ce5..1125701e4a9f38e15775cd2edbdbf98c1a00a1bd 100644 (file)
@@ -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 (file)
index 0000000..8be7e2d
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Relations\Pivot;
+
+class Restream extends Pivot {
+
+       public $incrementing = true;
+
+       protected $casts = [
+               'accept_comms' => 'boolean',
+               'accept_tracker' => 'boolean',
+       ];
+
+}
+
+?>
index 03abdff04f6564278e5d1fe2efccf313bc61e8a4..3e42c9f2790cc613809f9d158412951106f0e73a 100644 (file)
@@ -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.
         *
index 6be9ad02ec02855cf5da87a7945e19775fdd0a2b..1957bbe1f1c704dc9ee12b36c71f469ec1fe6353 100644 (file)
@@ -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 (file)
index 0000000..fe86f8a
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::table('channel_episode', function(Blueprint $table) {
+                       $table->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');
+               });
+       }
+};
index 384362c7b8c06a66fee950cbb4fb6a47f5f8a851..00a65a497f4d36b4a90ffc3a5a15615cada8d59d 100644 (file)
@@ -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 (file)
index 0000000..5f0422e
--- /dev/null
@@ -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 <Modal className="apply-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('episodes.applyDialog.title')}
+                       </Modal.Title>
+               </Modal.Header>
+               <ApplyForm
+                       as={as}
+                       episode={episode}
+                       onCancel={onHide}
+                       onSubmit={onSubmit}
+               />
+       </Modal>;
+};
+
+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 (file)
index 0000000..65befb7
--- /dev/null
@@ -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 <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <DialogEpisode episode={episode} />
+                       <Form.Group controlId="apply.role">
+                               <Form.Label>{t('episodes.applyDialog.signUpAs')}</Form.Label>
+                               <Form.Control
+                                       plaintext
+                                       readOnly
+                                       value={t(`crew.roles.${as}`)}
+                               />
+                       </Form.Group>
+                       <Form.Group controlId="apply.channel_id">
+                               <Form.Label>{t('episodes.channel')}</Form.Label>
+                               <Form.Select
+                                       isInvalid={!!(touched.channel_id && errors.channel_id)}
+                                       name="channel_id"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.channel_id || 0}
+                               >
+                                       <option disabled value={0}>{t('general.pleaseSelect')}</option>
+                                       {available_channels.map(c =>
+                                               <option key={c.id} value={c.id}>
+                                                       {c.title}
+                                               </option>
+                                       )}
+                               </Form.Select>
+                               {touched.channel_id && errors.channel_id ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.channel_id)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Modal.Body>
+               <Modal.Footer>
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.cancel')}
+                               </Button>
+                       : null}
+                       <Button type="submit" variant="primary">
+                               {t('button.submit')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+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));
index 0a037ee8ec7bb340700e7df33096244beea2f806..76a38a6604852733cb38a6fa32a3b44aab0ee51c 100644 (file)
@@ -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 <Row className="episode-crew">
-               {commentators.length ?
+               {commentators.length || canApplyForEpisode(user, episode, 'commentary') ?
                        <Col md>
                                <div className="fs-5">
                                        <Icon.MICROPHONE className="ms-3 me-2" title="" />
@@ -30,9 +32,19 @@ const Crew = ({ crew }) => {
                                {commentators.map(c =>
                                        <CrewMember crew={c} key={c.id} />
                                )}
+                               {onApply && canApplyForEpisode(user, episode, 'commentary') ?
+                                       <div className="button-bar m-2">
+                                               <Button
+                                                       onClick={() => onApply(episode, 'commentary')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       {t('button.signUp')}
+                                               </Button>
+                                       </div>
+                               : null}
                        </Col>
                : null}
-               {trackers.length ?
+               {trackers.length || canApplyForEpisode(user, episode, 'tracking') ?
                        <Col md>
                                <div className="fs-5">
                                        <Icon.MOUSE className="ms-3 me-2" title="" />
@@ -41,6 +53,16 @@ const Crew = ({ crew }) => {
                                {trackers.map(c =>
                                        <CrewMember crew={c} key={c.id} />
                                )}
+                               {onApply && canApplyForEpisode(user, episode, 'tracking') ?
+                                       <div className="button-bar m-2">
+                                               <Button
+                                                       onClick={() => onApply(episode, 'tracking')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       {t('button.signUp')}
+                                               </Button>
+                                       </div>
+                               : null}
                        </Col>
                : 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 (file)
index 0000000..7c5d48e
--- /dev/null
@@ -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 <div className="mt-2">
+               <div className="fs-4">{t(`crew.roles.${role}`)}</div>
+               {crews.map(crew =>
+                       <div className="d-flex align-items-center justify-content-between" key={crew.id}>
+                               <CrewMember crew={crew} />
+                               <div className="button-bar">
+                                       {crew.confirmed ?
+                                               <Button
+                                                       onClick={() => unconfirmCrew(crew)}
+                                                       title={t('button.unconfirm')}
+                                                       variant="outline-danger"
+                                               >
+                                                       <Icon.REJECT title="" />
+                                               </Button>
+                                       : null}
+                                       {!crew.confirmed ?
+                                               <Button
+                                                       onClick={() => confirmCrew(crew)}
+                                                       title={t('button.confirm')}
+                                                       variant="outline-success"
+                                               >
+                                                       <Icon.ACCEPT />
+                                               </Button>
+                                       : null}
+                                       <Button
+                                               onClick={() => removeCrew(crew)}
+                                               title={t('button.remove')}
+                                               variant="outline-danger"
+                                       >
+                                               <Icon.DELETE title="" />
+                                       </Button>
+                               </div>
+                       </div>
+               )}
+               <Form.Group controlId="crew.addUser">
+                       <Form.Label>{t('episodes.restreamDialog.addUser')}</Form.Label>
+                       <Form.Control
+                               as={UserSelect}
+                               onChange={e => addCrew(e.target.value)}
+                               value=""
+                       />
+               </Form.Group>
+       </div>;
+};
+
+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 (file)
index 0000000..c60b052
--- /dev/null
@@ -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 <>
+               <div>
+                       {episode.event.title}
+               </div>
+               <div>
+                       {t('episodes.startTime', { date: new Date(episode.start) })}
+               </div>
+               <div>
+                       {episode.players.map(p => getName(p)).join(', ')}
+               </div>
+       </>;
+};
+
+DialogEpisode.propTypes = {
+       episode: PropTypes.shape({
+               event: PropTypes.shape({
+                       title: PropTypes.string,
+               }),
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               start: PropTypes.string,
+       }),
+};
+
+export default DialogEpisode;
index 19760ee6c5624cb859cb5b65438558f067a56761..c911bc3b6e1f54e3ff15d09ceb76de4abbccefd0 100644 (file)
@@ -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 ?
                                <Players players={episode.players} />
                        : null}
-                       {episode.crew && episode.crew.length ?
-                               <Crew crew={episode.crew} />
+                       {(episode.crew && episode.crew.length)
+                                       || canApplyForEpisode(user, episode, 'commentary')
+                                       || canApplyForEpisode(user, episode, 'tracking') ?
+                               <Crew episode={episode} onApply={onApply} />
                        : null}
                </div>
        </div>;
@@ -116,6 +118,7 @@ Item.propTypes = {
                title: PropTypes.string,
        }),
        onAddRestream: PropTypes.func,
+       onApply: PropTypes.func,
        onEditRestream: PropTypes.func,
        user: PropTypes.shape({
        }),
index 6f46e75add208811a83362e9521fb041ca4ab5bd..a2a3b29f982d014bdcff9edd3db608843501a69a 100644 (file)
@@ -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 }) => {
                                <Item
                                        episode={episode}
                                        onAddRestream={onAddRestream}
+                                       onApply={onApply}
                                        onEditRestream={onEditRestream}
                                        key={episode.id}
                                />
@@ -38,6 +39,7 @@ List.propTypes = {
                start: PropTypes.string,
        })),
        onAddRestream: PropTypes.func,
+       onApply: PropTypes.func,
        onEditRestream: PropTypes.func,
 };
 
index 747b47e656b7107e2920dfb4c779ac3b0ffd884c..7cbb87d538e17b2102ebe95df68edfe9c78bba23 100644 (file)
@@ -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 <Form noValidate onSubmit={handleSubmit}>
                <Modal.Body>
-                       {episode ? <>
-                               <div>
-                                       {episode.event.title}
-                               </div>
-                               <div>
-                                       {t('episodes.startTime', { date: new Date(episode.start) })}
-                               </div>
-                               <div>
-                                       {episode.players.map(p => getName(p)).join(', ')}
-                               </div>
-                       </> : null}
+                       <DialogEpisode episode={episode} />
                        <Form.Group controlId="episodes.channel_id">
                                <Form.Label>{t('episodes.channel')}</Form.Label>
                                <Form.Select
@@ -56,6 +47,44 @@ const RestreamAddForm = ({
                                        </Form.Control.Feedback>
                                : null}
                        </Form.Group>
+                       <Row>
+                               <Form.Group as={Col} sm={6} controlId="episodes.accept_comms">
+                                       <Form.Label className="d-block">
+                                               {t('episodes.restreamDialog.acceptComms')}
+                                       </Form.Label>
+                                       <Form.Control
+                                               as={ToggleSwitch}
+                                               isInvalid={!!(touched.accept_comms && errors.accept_comms)}
+                                               name="accept_comms"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={!!values.accept_comms}
+                                       />
+                                       {touched.accept_comms && errors.accept_comms ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.accept_comms)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                               <Form.Group as={Col} sm={6} controlId="episodes.accept_tracker">
+                                       <Form.Label className="d-block">
+                                               {t('episodes.restreamDialog.acceptTracker')}
+                                       </Form.Label>
+                                       <Form.Control
+                                               as={ToggleSwitch}
+                                               isInvalid={!!(touched.accept_tracker && errors.accept_tracker)}
+                                               name="accept_tracker"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={!!values.accept_tracker}
+                                       />
+                                       {touched.accept_tracker && errors.accept_tracker ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.accept_tracker)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                       </Row>
                </Modal.Body>
                <Modal.Footer>
                        {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,
index 340b3c311a8a21f78cece554bd4134c27234f65a..2b7454bbc7f7d73aa4dd4f0460e6462a73388af2 100644 (file)
@@ -8,7 +8,9 @@ import RestreamEditForm from './RestreamEditForm';
 
 const RestreamDialog = ({
        channel,
+       editRestream,
        episode,
+       manageCrew,
        onHide,
        onRemoveRestream,
        onSubmit,
@@ -25,7 +27,9 @@ const RestreamDialog = ({
                {channel ?
                        <RestreamEditForm
                                channel={channel}
+                               editRestream={editRestream}
                                episode={episode}
+                               manageCrew={manageCrew}
                                onCancel={onHide}
                                onRemoveRestream={onRemoveRestream}
                        />
@@ -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,
index e4cbe134fe5e8980dd60065196601555b221de77..84a3ea2f6f0c59405935f06780c03a8b11e61e9d 100644 (file)
@@ -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 <>
                <Modal.Body>
                        {channel ?
@@ -31,6 +43,52 @@ const RestreamEditForm = ({
                                        {episode.players.map(p => getName(p)).join(', ')}
                                </div>
                        </> : null}
+                       {channel && episode && editRestream ?
+                               <Row>
+                                       <Form.Group as={Col} sm={6} controlId="episodes.accept_comms">
+                                               <Form.Label className="d-block">
+                                                       {t('episodes.restreamDialog.acceptComms')}
+                                               </Form.Label>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       name="accept_comms"
+                                                       onChange={acceptToggle}
+                                                       value={!!channel.pivot.accept_comms}
+                                               />
+                                       </Form.Group>
+                                       <Form.Group as={Col} sm={6} controlId="episodes.accept_tracker">
+                                               <Form.Label className="d-block">
+                                                       {t('episodes.restreamDialog.acceptTracker')}
+                                               </Form.Label>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       name="accept_tracker"
+                                                       onChange={acceptToggle}
+                                                       value={!!channel.pivot.accept_tracker}
+                                               />
+                                       </Form.Group>
+                               </Row>
+                       : null}
+                       {channel && episode && manageCrew ? <>
+                               <CrewManagement
+                                       channel={channel}
+                                       episode={episode}
+                                       manageCrew={manageCrew}
+                                       role="commentary"
+                               />
+                               <CrewManagement
+                                       channel={channel}
+                                       episode={episode}
+                                       manageCrew={manageCrew}
+                                       role="tracking"
+                               />
+                               <CrewManagement
+                                       channel={channel}
+                                       episode={episode}
+                                       manageCrew={manageCrew}
+                                       role="setup"
+                               />
+                       </> : null}
                </Modal.Body>
                <Modal.Footer className="justify-content-between">
                        {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,
 };
index 221fa987b38953416686eb616e8663f3de3b959c..3d0a14082b8d5262c8350d207b6186c6a04e6116 100644 (file)
@@ -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 }) => {
                                <List
                                        episodes={episodes}
                                        onAddRestream={onAddRestream}
+                                       onApply={onApply}
                                        onEditRestream={onEditRestream}
                                />
                        :
@@ -150,14 +237,25 @@ const Schedule = ({ user }) => {
                                </Alert>
                        }
                </ErrorBoundary>
-               <RestreamDialog
-                       channel={restreamChannel}
-                       episode={restreamEpisode}
-                       onRemoveRestream={onRemoveRestream}
-                       onHide={onHideRestreamDialog}
-                       onSubmit={onAddRestreamSubmit}
-                       show={showRestreamDialog}
-               />
+               {user ? <>
+                       <ApplyDialog
+                               as={applyAs}
+                               episode={restreamEpisode}
+                               onHide={onHideApplyDialog}
+                               onSubmit={onSubmitApplyDialog}
+                               show={showApplyDialog}
+                       />
+                       <RestreamDialog
+                               channel={restreamChannel}
+                               editRestream={editRestream}
+                               episode={restreamEpisode}
+                               manageCrew={manageCrew}
+                               onRemoveRestream={onRemoveRestream}
+                               onHide={onHideRestreamDialog}
+                               onSubmit={onAddRestreamSubmit}
+                               show={showRestreamDialog}
+                       />
+               </> : null}
        </Container>;
 };
 
diff --git a/resources/js/helpers/Episode.js b/resources/js/helpers/Episode.js
new file mode 100644 (file)
index 0000000..675fb36
--- /dev/null
@@ -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));
+};
index 835af24787585f620f935a4c23834166d1e430a9..54bf905fe473a9224419c4325e1b6b9b5c72d5a2 100644 (file)
@@ -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
index ae1b29e8efead25d0e0c68dc2bbc92fa65285737..389c300c193fe8df68704c309679ad194cafc175 100644 (file)
@@ -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',
index e499651abced10bc3a22888c06e8554975218130..ff1905d8f66886df214d96425d8d2d21dea4a8a3 100644 (file)
@@ -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',
index e80df7cee808c02df2530d0f17e86949d39fe041..bcc81c8faf079a5be1d1dc9799f8a26790752b7f 100644 (file)
@@ -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;
index df98cd7a9b52f73c712ace28c70e2e128dfdbb55..d4248cb2e2bd2dc4f10d63f16ecc591df273a4a0 100644 (file)
                        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;
                }
        }
 }
index 7980c135df6cc7aadcd03454eb2225b5f3f1abc3..8fdfad417e6e69ba090bdd40ae4bdfae265a51ec 100644 (file)
@@ -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');