use App\Models\Channel;
use App\Models\Episode;
+use App\Models\EpisodeCrew;
+use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
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',
]);
}
}
- $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();
}
}
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) {
return $this->belongsTo(User::class);
}
+ protected $casts = [
+ 'user_id' => 'string',
+ ];
+
}
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() {
protected $casts = [
'confirmed' => 'boolean',
+ 'user_id' => 'string',
+ ];
+
+ protected $fillable = [
+ 'channel_id',
+ 'episode_id',
+ 'role',
+ 'user_id',
];
protected $hidden = [
return $this->belongsTo(User::class);
}
+ protected $casts = [
+ 'user_id' => 'string',
+ ];
+
protected $hidden = [
'created_at',
'ext_id',
--- /dev/null
+<?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',
+ ];
+
+}
+
+?>
->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.
*
->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.
*
--- /dev/null
+<?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');
+ });
+ }
+};
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');
--- /dev/null
+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;
--- /dev/null
+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));
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="" />
{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="" />
{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 ?
};
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);
--- /dev/null
+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;
--- /dev/null
+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;
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 => {
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 = [
{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>;
title: PropTypes.string,
}),
onAddRestream: PropTypes.func,
+ onApply: PropTypes.func,
onEditRestream: PropTypes.func,
user: PropTypes.shape({
}),
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 {
<Item
episode={episode}
onAddRestream={onAddRestream}
+ onApply={onApply}
onEditRestream={onEditRestream}
key={episode.id}
/>
start: PropTypes.string,
})),
onAddRestream: PropTypes.func,
+ onApply: PropTypes.func,
onEditRestream: PropTypes.func,
};
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';
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
</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 ?
start: PropTypes.string,
}),
errors: PropTypes.shape({
+ accept_comms: PropTypes.string,
+ accept_tracker: PropTypes.string,
channel_id: PropTypes.string,
}),
handleBlur: PropTypes.func,
handleSubmit: PropTypes.func,
onCancel: PropTypes.func,
touched: PropTypes.shape({
+ accept_comms: PropTypes.bool,
+ accept_tracker: PropTypes.bool,
channel_id: PropTypes.bool,
}),
user: PropTypes.shape({
})),
}),
values: PropTypes.shape({
+ accept_comms: PropTypes.bool,
+ accept_tracker: PropTypes.bool,
channel_id: PropTypes.number,
}),
};
}
},
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,
const RestreamDialog = ({
channel,
+ editRestream,
episode,
+ manageCrew,
onHide,
onRemoveRestream,
onSubmit,
{channel ?
<RestreamEditForm
channel={channel}
+ editRestream={editRestream}
episode={episode}
+ manageCrew={manageCrew}
onCancel={onHide}
onRemoveRestream={onRemoveRestream}
/>
RestreamDialog.propTypes = {
channel: PropTypes.shape({
}),
+ editRestream: PropTypes.func,
episode: PropTypes.shape({
}),
+ manageCrew: PropTypes.func,
onHide: PropTypes.func,
onRemoveRestream: PropTypes.func,
onSubmit: PropTypes.func,
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 ?
{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 ?
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,
};
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';
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();
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);
<List
episodes={episodes}
onAddRestream={onAddRestream}
+ onApply={onApply}
onEditRestream={onEditRestream}
/>
:
</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>;
};
--- /dev/null
+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));
+};
/// 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';
// 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);
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
cancel: 'Abbrechen',
chart: 'Diagramm',
close: 'Schließen',
+ confirm: 'Bestätigen',
edit: 'Bearbeiten',
generate: 'Generieren',
help: 'Hilfe',
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',
cancel: 'Cancel',
chart: 'Chart',
close: 'Close',
+ confirm: 'Confirm',
edit: 'Edit',
generate: 'Generate',
help: 'Help',
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',
$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;
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;
}
}
}
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');