]> git.localhorst.tv Git - alttp.git/commitdiff
basic channel crew
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 22 Feb 2023 14:57:46 +0000 (15:57 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Wed, 22 Feb 2023 14:57:46 +0000 (15:57 +0100)
19 files changed:
app/Http/Controllers/EpisodeController.php
app/Models/ChannelCrew.php [new file with mode: 0644]
app/Models/User.php
app/Policies/ChannelPolicy.php [new file with mode: 0644]
app/Policies/EpisodePolicy.php
database/migrations/2023_02_22_091842_create_channel_crews_table.php [new file with mode: 0644]
resources/js/components/episodes/Channel.js
resources/js/components/episodes/Channels.js
resources/js/components/episodes/Item.js
resources/js/components/episodes/List.js
resources/js/components/episodes/RestreamAddForm.js [new file with mode: 0644]
resources/js/components/episodes/RestreamDialog.js [new file with mode: 0644]
resources/js/components/episodes/RestreamEditForm.js [new file with mode: 0644]
resources/js/components/pages/Schedule.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/episodes.scss
routes/api.php

index 6de7d676a496c6b6ee6aaa89ccff01d9540a5b42..1a407a184e97039738e4b3743f888fec05a3f924 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace App\Http\Controllers;
 
+use App\Models\Channel;
 use App\Models\Episode;
 use Carbon\Carbon;
 use Illuminate\Http\Request;
@@ -9,6 +10,40 @@ use Illuminate\Http\Request;
 class EpisodeController extends Controller
 {
 
+       public function addRestream(Request $request, Episode $episode) {
+               $this->authorize('addRestream', $episode);
+               $validatedData = $request->validate([
+                       'channel_id' => 'numeric|exists:App\Models\Channel,id',
+               ]);
+
+               $channel = Channel::find($validatedData['channel_id']);
+               $this->authorize('addEpisode', $channel);
+
+               foreach ($episode->channels as $c) {
+                       if ($c->id == $channel->id) {
+                               throw new \Exception('channel already exists on episode');
+                       }
+               }
+
+               $episode->channels()->attach($channel);
+
+               return $episode->load('channels')->toJson();
+       }
+
+       public function removeRestream(Request $request, Episode $episode) {
+               $this->authorize('removeRestream', $episode);
+               $validatedData = $request->validate([
+                       'channel_id' => 'numeric|exists:App\Models\Channel,id',
+               ]);
+
+               $channel = Channel::find($validatedData['channel_id']);
+               $this->authorize('removeEpisode', $channel);
+
+               $episode->channels()->detach($channel);
+
+               return $episode->load('channels')->toJson();
+       }
+
        public function search(Request $request) {
                $validatedData = $request->validate([
                        'after' => 'nullable|date',
diff --git a/app/Models/ChannelCrew.php b/app/Models/ChannelCrew.php
new file mode 100644 (file)
index 0000000..23c758e
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class ChannelCrew extends Model
+{
+       use HasFactory;
+
+       public function channel() {
+               return $this->belongsTo(Channel::class);
+       }
+
+       public function user() {
+               return $this->belongsTo(User::class);
+       }
+
+}
index 201361e68534eb869f94a1c3e50dc61140552a6b..ad5215f064acc5407b2d1bc48664c549c3e41fbb 100644 (file)
@@ -115,6 +115,10 @@ class User extends Authenticatable
        }
 
 
+       public function channel_crews() {
+               return $this->hasMany(ChannelCrew::class);
+       }
+
        public function participation() {
                return $this->hasMany(Participant::class);
        }
diff --git a/app/Policies/ChannelPolicy.php b/app/Policies/ChannelPolicy.php
new file mode 100644 (file)
index 0000000..03abdff
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\Channel;
+use App\Models\User;
+use Illuminate\Auth\Access\HandlesAuthorization;
+
+class ChannelPolicy
+{
+       use HandlesAuthorization;
+
+       /**
+        * Determine whether the user can view any models.
+        *
+        * @param  \App\Models\User  $user
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function viewAny(?User $user)
+       {
+               return true;
+       }
+
+       /**
+        * Determine whether the user can view the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function view(User $user, Channel $channel)
+       {
+               return $channel->event->visible;
+       }
+
+       /**
+        * Determine whether the user can create models.
+        *
+        * @param  \App\Models\User  $user
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function create(User $user)
+       {
+               return $user->isAdmin();
+       }
+
+       /**
+        * Determine whether the user can update the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function update(User $user, Channel $channel)
+       {
+               return $user->isAdmin();
+       }
+
+       /**
+        * Determine whether the user can delete the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function delete(User $user, Channel $channel)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can restore the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function restore(User $user, Channel $channel)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can permanently delete the model.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function forceDelete(User $user, Channel $channel)
+       {
+               return false;
+       }
+
+       /**
+        * Determine whether the user can add episodes to the channel.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function addEpisode(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.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Channel  $channel
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function removeEpisode(User $user, Channel $channel) {
+               return $user->channel_crews()
+                         ->where('role', '=', 'admin')
+                         ->where('channel_id', '=', $channel->id)
+                         ->count() > 0;
+       }
+
+}
index 18988cfa2918e0e967db4e37ddc2d7c8b5347f34..6be9ad02ec02855cf5da87a7945e19775fdd0a2b 100644 (file)
@@ -91,4 +91,33 @@ class EpisodePolicy
        {
                return false;
        }
+
+       /**
+        * Determine whether the user can add restreams for the episode.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Episode  $episode
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function addRestream(User $user, Episode $episode) {
+               return $user->channel_crews()
+                         ->where('role', '=', 'admin')
+                         ->whereNotIn('channel_id', $episode->channels->pluck('id'))
+                         ->count() > 0;
+       }
+
+       /**
+        * Determine whether the user can remove restreams from the episode.
+        *
+        * @param  \App\Models\User  $user
+        * @param  \App\Models\Episode  $episode
+        * @return \Illuminate\Auth\Access\Response|bool
+        */
+       public function removeRestream(User $user, Episode $episode) {
+               return $user->channel_crews()
+                         ->where('role', '=', 'admin')
+                         ->whereIn('channel_id', $episode->channels->pluck('id'))
+                         ->count() > 0;
+       }
+
 }
diff --git a/database/migrations/2023_02_22_091842_create_channel_crews_table.php b/database/migrations/2023_02_22_091842_create_channel_crews_table.php
new file mode 100644 (file)
index 0000000..e4ed221
--- /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::create('channel_crews', function (Blueprint $table) {
+                       $table->id();
+                       $table->foreignId('user_id')->constrained();
+                       $table->foreignId('channel_id')->constrained();
+                       $table->string('role')->default('helper');
+                       $table->timestamps();
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::dropIfExists('channel_crews');
+       }
+};
index 1943753617095c1da4b5da6075f77c6f1b05f892..5b6c5e26e007dc67f2bc2fa4f2e3fc065b96ee19 100644 (file)
@@ -3,8 +3,10 @@ import React from 'react';
 import { Button } from 'react-bootstrap';
 
 import Icon from '../common/Icon';
+import { mayEditRestream } from '../../helpers/permissions';
+import { withUser } from '../../helpers/UserContext';
 
-const Channel = ({ channel }) =>
+const Channel = ({ channel, episode, onEditRestream, user }) =>
        <div className="episode-channel">
                <Button
                        href={channel.stream_link}
@@ -17,6 +19,15 @@ const Channel = ({ channel }) =>
                        {' '}
                        {channel.short_name || channel.title}
                </Button>
+               {onEditRestream && mayEditRestream(user, episode, channel) ?
+                       <Button
+                               className="ms-1"
+                               onClick={() => onEditRestream(episode, channel)}
+                               variant="outline-secondary"
+                       >
+                               <Icon.SETTINGS />
+                       </Button>
+               : null}
        </div>;
 
 Channel.propTypes = {
@@ -25,6 +36,11 @@ Channel.propTypes = {
                stream_link: PropTypes.string,
                title: PropTypes.string,
        }),
+       episode: PropTypes.shape({
+       }),
+       onEditRestream: PropTypes.func,
+       user: PropTypes.shape({
+       }),
 };
 
-export default Channel;
+export default withUser(Channel);
index fb1014779f6257e69d3e7435aa511c2b154e1918..f3fbf05544218781f714446a640cbcfa8d34dd7b 100644 (file)
@@ -3,16 +3,22 @@ import React from 'react';
 
 import Channel from './Channel';
 
-const Channels = ({ channels }) =>
-       <div className="episode-channels text-right">
-               {channels.map(channel =>
-                       <Channel channel={channel} key={channel.id} />
-               )}
-       </div>;
+const Channels = ({ channels, episode, onEditRestream }) =>
+       channels.map(channel =>
+               <Channel
+                       channel={channel}
+                       episode={episode}
+                       key={channel.id}
+                       onEditRestream={onEditRestream}
+               />
+       );
 
 Channels.propTypes = {
        channels: PropTypes.arrayOf(PropTypes.shape({
        })),
+       episode: PropTypes.shape({
+       }),
+       onEditRestream: PropTypes.func,
 };
 
 export default Channels;
index 5c6982972a6dbd57ca176bdfe0b296abb889d7ba..71fe49ed5c6875eb00d34848143160a75576fc02 100644 (file)
@@ -1,12 +1,16 @@
 import moment from 'moment';
 import PropTypes from 'prop-types';
 import React from 'react';
+import { Button } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 
 import Channels from './Channels';
 import Crew from './Crew';
 import MultiLink from './MultiLink';
 import Players from './Players';
+import Icon from '../common/Icon';
+import { canRestreamEpisode } from '../../helpers/permissions';
+import { withUser } from '../../helpers/UserContext';
 
 const isActive = episode => {
        if (!episode.start) return false;
@@ -16,7 +20,7 @@ const isActive = episode => {
        return start.isBefore(now) && end.isAfter(now);
 };
 
-const Item = ({ episode }) => {
+const Item = ({ episode, onAddRestream, onEditRestream, user }) => {
        const { t } = useTranslation();
 
        const classNames = [
@@ -53,13 +57,29 @@ const Item = ({ episode }) => {
                                                </div>
                                        : null}
                                </div>
-                               <div>
+                               <div className="episode-channel-links text-end">
                                        {hasChannels ?
-                                               <Channels channels={episode.channels} />
+                                               <Channels
+                                                       channels={episode.channels}
+                                                       episode={episode}
+                                                       onEditRestream={onEditRestream}
+                                               />
                                        : null}
                                        {!hasChannels && hasPlayers ?
                                                <MultiLink players={episode.players} />
                                        : null}
+                                       {onAddRestream && canRestreamEpisode(user, episode) ?
+                                               <div>
+                                                       <Button
+                                                               onClick={() => onAddRestream(episode)}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               <Icon.ADD title="" />
+                                                               {' '}
+                                                               {t('episodes.addRestream')}
+                                                       </Button>
+                                               </div>
+                                       : null}
                                </div>
                        </div>
                        {hasPlayers ?
@@ -86,6 +106,10 @@ Item.propTypes = {
                start: PropTypes.string,
                title: PropTypes.string,
        }),
+       onAddRestream: PropTypes.func,
+       onEditRestream: PropTypes.func,
+       user: PropTypes.shape({
+       }),
 };
 
-export default Item;
+export default withUser(Item);
index d4e73b51b2711559def11f2d670a036d505b5325..a28fa4848968b9e2fa2a455cec44c55b7b74a8c9 100644 (file)
@@ -4,7 +4,7 @@ import React from 'react';
 
 import Item from './Item';
 
-const List = ({ episodes }) => {
+const List = ({ episodes, onAddRestream, onEditRestream }) => {
        const grouped = React.useMemo(() => episodes.reduce((groups, episode) => {
                const day = moment(episode.start).format('YYYY-MM-DD');
                return {
@@ -20,7 +20,12 @@ const List = ({ episodes }) => {
                {Object.entries(grouped).map(([day, group]) => <div key={day}>
                        <h2 className="text-center my-5">{moment(day).format('dddd, L')}</h2>
                        {group.map(episode =>
-                               <Item episode={episode} key={episode.id} />
+                               <Item
+                                       episode={episode}
+                                       onAddRestream={onAddRestream}
+                                       onEditRestream={onEditRestream}
+                                       key={episode.id}
+                               />
                        )}
                </div>)}
        </div>;
@@ -30,6 +35,8 @@ List.propTypes = {
        episodes: PropTypes.arrayOf(PropTypes.shape({
                start: PropTypes.string,
        })),
+       onAddRestream: PropTypes.func,
+       onEditRestream: PropTypes.func,
 };
 
 export default List;
diff --git a/resources/js/components/episodes/RestreamAddForm.js b/resources/js/components/episodes/RestreamAddForm.js
new file mode 100644 (file)
index 0000000..747b47e
--- /dev/null
@@ -0,0 +1,120 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { getName } from '../../helpers/Crew';
+import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
+import { withUser } from '../../helpers/UserContext';
+
+const RestreamAddForm = ({
+       episode,
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       user,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       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}
+                       <Form.Group controlId="episodes.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>
+                                       {((user && user.channel_crews) || []).map(c =>
+                                               <option key={c.id} value={c.channel_id}>
+                                                       {c.channel.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.save')}
+                       </Button>
+               </Modal.Footer>
+       </Form>;
+};
+
+RestreamAddForm.propTypes = {
+       episode: PropTypes.shape({
+               event: PropTypes.shape({
+                       title: PropTypes.string,
+               }),
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               start: PropTypes.string,
+       }),
+       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({
+               channel_crews: PropTypes.arrayOf(PropTypes.shape({
+               })),
+       }),
+       values: PropTypes.shape({
+               channel_id: PropTypes.number,
+       }),
+};
+
+export default withUser(withFormik({
+       displayName: 'RestreamAddForm',
+       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: ({ episode, user }) => ({
+               channel_id: user && user.channel_crews && user.channel_crews.length
+                       ? user.channel_crews[0].channel_id : 0,
+               episode_id: episode ? episode.id : 0,
+       }),
+})(RestreamAddForm));
diff --git a/resources/js/components/episodes/RestreamDialog.js b/resources/js/components/episodes/RestreamDialog.js
new file mode 100644 (file)
index 0000000..340b3c3
--- /dev/null
@@ -0,0 +1,53 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import RestreamAddForm from './RestreamAddForm';
+import RestreamEditForm from './RestreamEditForm';
+
+const RestreamDialog = ({
+       channel,
+       episode,
+       onHide,
+       onRemoveRestream,
+       onSubmit,
+       show,
+}) => {
+       const { t } = useTranslation();
+
+       return <Modal className="restream-dialog" onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t('episodes.restreamDialog.title')}
+                       </Modal.Title>
+               </Modal.Header>
+               {channel ?
+                       <RestreamEditForm
+                               channel={channel}
+                               episode={episode}
+                               onCancel={onHide}
+                               onRemoveRestream={onRemoveRestream}
+                       />
+               :
+                       <RestreamAddForm
+                               episode={episode}
+                               onCancel={onHide}
+                               onSubmit={onSubmit}
+                       />
+               }
+       </Modal>;
+};
+
+RestreamDialog.propTypes = {
+       channel: PropTypes.shape({
+       }),
+       episode: PropTypes.shape({
+       }),
+       onHide: PropTypes.func,
+       onRemoveRestream: PropTypes.func,
+       onSubmit: PropTypes.func,
+       show: PropTypes.bool,
+};
+
+export default RestreamDialog;
diff --git a/resources/js/components/episodes/RestreamEditForm.js b/resources/js/components/episodes/RestreamEditForm.js
new file mode 100644 (file)
index 0000000..e4cbe13
--- /dev/null
@@ -0,0 +1,66 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Modal } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import { getName } from '../../helpers/Crew';
+
+const RestreamEditForm = ({
+       channel,
+       episode,
+       onCancel,
+       onRemoveRestream,
+}) => {
+       const { t } = useTranslation();
+
+       return <>
+               <Modal.Body>
+                       {channel ?
+                               <div>
+                                       {channel.title}
+                               </div>
+                       : null}
+                       {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}
+               </Modal.Body>
+               <Modal.Footer className="justify-content-between">
+                       {onRemoveRestream ?
+                               <Button onClick={() => onRemoveRestream(episode, channel)} variant="outline-danger">
+                                       {t('button.remove')}
+                               </Button>
+                       : null}
+                       {onCancel ?
+                               <Button onClick={onCancel} variant="secondary">
+                                       {t('button.close')}
+                               </Button>
+                       : null}
+               </Modal.Footer>
+       </>;
+};
+
+RestreamEditForm.propTypes = {
+       channel: PropTypes.shape({
+               title: PropTypes.string,
+       }),
+       episode: PropTypes.shape({
+               event: PropTypes.shape({
+                       title: PropTypes.string,
+               }),
+               players: PropTypes.arrayOf(PropTypes.shape({
+               })),
+               start: PropTypes.string,
+       }),
+       onCancel: PropTypes.func,
+       onRemoveRestream: PropTypes.func,
+};
+
+export default RestreamEditForm;
index 59e6ac4846eeff8aee216d632d90a773a0706170..10b4b4977efc925ab286326075f0739bc73731c3 100644 (file)
@@ -1,20 +1,27 @@
 import axios from 'axios';
 import moment from 'moment';
+import PropTypes from 'prop-types';
 import React from 'react';
 import { Alert, Container } from 'react-bootstrap';
 import { Helmet } from 'react-helmet';
 import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
 
 import CanonicalLinks from '../common/CanonicalLinks';
 import ErrorBoundary from '../common/ErrorBoundary';
 import Filter from '../episodes/Filter';
 import List from '../episodes/List';
+import RestreamDialog from '../episodes/RestreamDialog';
+import { withUser } from '../../helpers/UserContext';
 
-const Schedule = () => {
-       const [ahead, setAhead] = React.useState(14);
-       const [behind, setBehind] = React.useState(0);
+const Schedule = ({ user }) => {
+       const [ahead] = React.useState(14);
+       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 [showRestreamDialog, setShowRestreamDialog] = React.useState(false);
 
        const { t } = useTranslation();
 
@@ -49,6 +56,63 @@ const Schedule = () => {
                });
        }, []);
 
+       const onAddRestream = React.useCallback(episode => {
+               setRestreamEpisode(episode);
+               setShowRestreamDialog(true);
+       }, []);
+
+       const onAddRestreamSubmit = React.useCallback(async values => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${values.episode_id}/add-restream`, values);
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       toastr.success(t('episodes.restreamDialog.addSuccess'));
+               } catch (e) {
+                       toastr.error(t('episodes.restreamDialog.addError'));
+                       throw e;
+               }
+               setRestreamEpisode(null);
+               setShowRestreamDialog(false);
+       }, []);
+
+       const onRemoveRestream = React.useCallback(async (episode, channel) => {
+               try {
+                       const response = await axios.post(
+                               `/api/episodes/${episode.id}/remove-restream`, { channel_id: channel.id });
+                       const newEpisode = response.data;
+                       setEpisodes(episodes => episodes.map(episode =>
+                               episode.id === newEpisode.id ? {
+                                       ...episode,
+                                       ...newEpisode,
+                               } : episode
+                       ));
+                       toastr.success(t('episodes.restreamDialog.removeSuccess'));
+                       setRestreamChannel(null);
+                       setRestreamEpisode(null);
+                       setShowRestreamDialog(false);
+               } catch (e) {
+                       toastr.error(t('episodes.restreamDialog.removeError'));
+               }
+       }, []);
+
+       const onEditRestream = React.useCallback((episode, channel) => {
+               setRestreamChannel(channel);
+               setRestreamEpisode(episode);
+               setShowRestreamDialog(true);
+       }, []);
+
+       const onHideRestreamDialog = React.useCallback(() => {
+               setShowRestreamDialog(false);
+               setRestreamChannel(null);
+               setRestreamEpisode(null);
+       }, []);
+
        React.useEffect(() => {
                const controller = new AbortController();
                fetchEpisodes(controller, ahead, behind, filter);
@@ -75,14 +139,31 @@ const Schedule = () => {
                </div>
                <ErrorBoundary>
                        {episodes.length ?
-                               <List episodes={episodes} />
+                               <List
+                                       episodes={episodes}
+                                       onAddRestream={onAddRestream}
+                                       onEditRestream={onEditRestream}
+                               />
                        :
                                <Alert variant="info">
                                        {t('episodes.empty')}
                                </Alert>
                        }
                </ErrorBoundary>
+               <RestreamDialog
+                       channel={restreamChannel}
+                       episode={restreamEpisode}
+                       onRemoveRestream={onRemoveRestream}
+                       onHide={onHideRestreamDialog}
+                       onSubmit={onAddRestreamSubmit}
+                       show={showRestreamDialog}
+               />
        </Container>;
 };
 
-export default Schedule;
+Schedule.propTypes = {
+       user: PropTypes.shape({
+       }),
+};
+
+export default withUser(Schedule);
index b1de05180d2b1b9ea04f83a4921a9cbd56190162..835af24787585f620f935a4c23834166d1e430a9 100644 (file)
@@ -7,6 +7,33 @@ export const isAdmin = user => user && user.role === 'admin';
 
 export const isSameUser = (user, subject) => user && subject && user.id === subject.id;
 
+// Channels
+
+export const isChannelAdmin = (user, channel) =>
+       user && channel && user.channel_crews &&
+               user.channel_crews.find(c => c.role === 'admin' && c.channel_id === channel.id);
+
+// Episodes
+
+export const episodeHasChannel = (episode, channel) =>
+       episode && channel && episode.channels && episode.channels.find(c => c.id === channel.id);
+
+export const mayRestreamEpisodes = user =>
+       user && user.channel_crews && user.channel_crews.find(c => c.role === 'admin');
+
+export const mayEditRestream = (user, episode, channel) =>
+       episodeHasChannel(episode, channel) && isChannelAdmin(user, channel);
+
+export const canRestreamEpisode = (user, episode) => {
+       if (!user || !episode || !mayRestreamEpisodes(user)) return false;
+       const available_channels = user.channel_crews
+               .filter(c => c.role === 'admin')
+               .map(c => c.channel_id);
+       const claimed_channels = ((episode && episode.channels) || []).map(c => c.id);
+       const remaining_channels = available_channels.filter(id => !claimed_channels.includes(id));
+       return remaining_channels.length > 0;
+};
+
 // Tournaments
 
 export const isApplicant = (user, tournament) => {
index 32cc3709dc8043cef32571f7940a246de7f4dc5b..ae1b29e8efead25d0e0c68dc2bbc92fa65285737 100644 (file)
@@ -257,15 +257,26 @@ export default {
                        logout: 'Logout',
                        new: 'Neu',
                        protocol: 'Protokoll',
+                       remove: 'Entfernen',
                        retry: 'Neu versuchen',
                        save: 'Speichern',
                        search: 'Suche',
                        settings: 'Einstellungen',
                },
                episodes: {
+                       addRestream: 'Neuer Restream',
+                       channel: 'Kanal',
                        commentary: 'Kommentar',
                        empty: 'Keine anstehenden Termine.',
+                       restreamDialog: {
+                               addError: 'Fehler beim Hinzufügen',
+                               addSuccess: 'Hinzugefügt',
+                               removeError: 'Fehler beim Entfernen',
+                               removeSuccess: 'Entfernt',
+                               title: 'Restream',
+                       },
                        setup: 'Setup',
+                       startTime: '{{ date, LL LT }} Uhr',
                        tracking: 'Tracking',
                },
                error: {
@@ -300,6 +311,7 @@ export default {
                        anonymous: 'Anonym',
                        appDescription: 'Turniere und Tutorials für The Legend of Zelda: A Link to the Past Randomizer',
                        appName: 'ALttP',
+                       pleaseSelect: 'Bitte wählen',
                },
                icon: {
                        AddIcon: 'Hinzufügen',
index 200e9888db9e1f41695a2a4ee92fcfe8cb6ad17a..e499651abced10bc3a22888c06e8554975218130 100644 (file)
@@ -257,15 +257,26 @@ export default {
                        logout: 'Logout',
                        new: 'New',
                        protocol: 'Protocol',
+                       remove: 'Remove',
                        retry: 'Retry',
                        save: 'Save',
                        search: 'Search',
                        settings: 'Settings',
                },
                episodes: {
+                       addRestream: 'Add Restream',
+                       channel: 'Channel',
                        commentary: 'Commentary',
                        empty: 'No dates coming up.',
+                       restreamDialog: {
+                               addError: 'Error adding restream',
+                               addSuccess: 'Added',
+                               removeError: 'Error removing restream',
+                               removeSuccess: 'Removed',
+                               title: 'Restream',
+                       },
                        setup: 'Setup',
+                       startTime: '{{ date, LL LT }}',
                        tracking: 'Tracking',
                },
                error: {
@@ -300,6 +311,7 @@ export default {
                        anonymous: 'Anonym',
                        appDescription: 'Tournaments and tutorials for The Legend of Zelda: A Link to the Past Randomizer',
                        appName: 'ALttP',
+                       pleaseSelect: 'Please select',
                },
                icon: {
                        AddIcon: 'Add',
index 3e801d44e1452604b813f88550d714fd8092d944..d4d686b6950241615b10a587686f26e3517d63bb 100644 (file)
@@ -7,6 +7,15 @@
        .episode-start {
                width: 4rem;
        }
+       .episode-channel-links > * {
+               margin: 0.5ex 0;
+               &:first-child {
+                       margin-top: 0;
+               }
+               &:last-child {
+                       margin-bottom: 0;
+               }
+       }
        .episode-players {
                display: grid;
                grid-template-columns: 1fr 1fr;
index 8c7e086ceb2e49e307904783efa484ed2251a579..7980c135df6cc7aadcd03454eb2225b5f3f1abc3 100644 (file)
@@ -15,7 +15,7 @@ use Illuminate\Support\Facades\Route;
 */
 
 Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
-       return $request->user();
+       return $request->user()->load(['channel_crews', 'channel_crews.channel']);
 });
 
 Route::get('alttp-seed/{hash}', 'App\Http\Controllers\AlttpSeedController@byHash');
@@ -37,6 +37,8 @@ Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildContro
 Route::get('discord-guilds/{guild_id}/channels', 'App\Http\Controllers\DiscordChannelController@search');
 
 Route::get('episodes', 'App\Http\Controllers\EpisodeController@search');
+Route::post('episodes/{episode}/add-restream', 'App\Http\Controllers\EpisodeController@addRestream');
+Route::post('episodes/{episode}/remove-restream', 'App\Http\Controllers\EpisodeController@removeRestream');
 
 Route::get('events', 'App\Http\Controllers\EventController@search');
 Route::get('events/{event:name}', 'App\Http\Controllers\EventController@single');