namespace App\Http\Controllers;
+use App\Models\Channel;
use App\Models\Episode;
use Carbon\Carbon;
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',
--- /dev/null
+<?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);
+ }
+
+}
}
+ public function channel_crews() {
+ return $this->hasMany(ChannelCrew::class);
+ }
+
public function participation() {
return $this->hasMany(Participant::class);
}
--- /dev/null
+<?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;
+ }
+
+}
{
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;
+ }
+
}
--- /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::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');
+ }
+};
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}
{' '}
{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 = {
stream_link: PropTypes.string,
title: PropTypes.string,
}),
+ episode: PropTypes.shape({
+ }),
+ onEditRestream: PropTypes.func,
+ user: PropTypes.shape({
+ }),
};
-export default Channel;
+export default withUser(Channel);
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;
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;
return start.isBefore(now) && end.isAfter(now);
};
-const Item = ({ episode }) => {
+const Item = ({ episode, onAddRestream, onEditRestream, user }) => {
const { t } = useTranslation();
const classNames = [
</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 ?
start: PropTypes.string,
title: PropTypes.string,
}),
+ onAddRestream: PropTypes.func,
+ onEditRestream: PropTypes.func,
+ user: PropTypes.shape({
+ }),
};
-export default Item;
+export default withUser(Item);
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 {
{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>;
episodes: PropTypes.arrayOf(PropTypes.shape({
start: PropTypes.string,
})),
+ onAddRestream: PropTypes.func,
+ onEditRestream: PropTypes.func,
};
export default List;
--- /dev/null
+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));
--- /dev/null
+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;
--- /dev/null
+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;
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();
});
}, []);
+ 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);
</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);
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) => {
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: {
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',
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: {
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',
.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;
*/
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');
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');