From 638802eaf20d636c16d7ce337ace508708705f2c Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Wed, 22 Feb 2023 15:57:46 +0100 Subject: [PATCH] basic channel crew --- app/Http/Controllers/EpisodeController.php | 35 +++++ app/Models/ChannelCrew.php | 20 +++ app/Models/User.php | 4 + app/Policies/ChannelPolicy.php | 123 ++++++++++++++++++ app/Policies/EpisodePolicy.php | 29 +++++ ...2_22_091842_create_channel_crews_table.php | 34 +++++ resources/js/components/episodes/Channel.js | 20 ++- resources/js/components/episodes/Channels.js | 18 ++- resources/js/components/episodes/Item.js | 32 ++++- resources/js/components/episodes/List.js | 11 +- .../js/components/episodes/RestreamAddForm.js | 120 +++++++++++++++++ .../js/components/episodes/RestreamDialog.js | 53 ++++++++ .../components/episodes/RestreamEditForm.js | 66 ++++++++++ resources/js/components/pages/Schedule.js | 91 ++++++++++++- resources/js/helpers/permissions.js | 27 ++++ resources/js/i18n/de.js | 12 ++ resources/js/i18n/en.js | 12 ++ resources/sass/episodes.scss | 9 ++ routes/api.php | 4 +- 19 files changed, 700 insertions(+), 20 deletions(-) create mode 100644 app/Models/ChannelCrew.php create mode 100644 app/Policies/ChannelPolicy.php create mode 100644 database/migrations/2023_02_22_091842_create_channel_crews_table.php create mode 100644 resources/js/components/episodes/RestreamAddForm.js create mode 100644 resources/js/components/episodes/RestreamDialog.js create mode 100644 resources/js/components/episodes/RestreamEditForm.js diff --git a/app/Http/Controllers/EpisodeController.php b/app/Http/Controllers/EpisodeController.php index 6de7d67..1a407a1 100644 --- a/app/Http/Controllers/EpisodeController.php +++ b/app/Http/Controllers/EpisodeController.php @@ -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 index 0000000..23c758e --- /dev/null +++ b/app/Models/ChannelCrew.php @@ -0,0 +1,20 @@ +belongsTo(Channel::class); + } + + public function user() { + return $this->belongsTo(User::class); + } + +} diff --git a/app/Models/User.php b/app/Models/User.php index 201361e..ad5215f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 index 0000000..03abdff --- /dev/null +++ b/app/Policies/ChannelPolicy.php @@ -0,0 +1,123 @@ +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; + } + +} diff --git a/app/Policies/EpisodePolicy.php b/app/Policies/EpisodePolicy.php index 18988cf..6be9ad0 100644 --- a/app/Policies/EpisodePolicy.php +++ b/app/Policies/EpisodePolicy.php @@ -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 index 0000000..e4ed221 --- /dev/null +++ b/database/migrations/2023_02_22_091842_create_channel_crews_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/resources/js/components/episodes/Channel.js b/resources/js/components/episodes/Channel.js index 1943753..5b6c5e2 100644 --- a/resources/js/components/episodes/Channel.js +++ b/resources/js/components/episodes/Channel.js @@ -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 }) =>
+ {onEditRestream && mayEditRestream(user, episode, channel) ? + + : null}
; 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); diff --git a/resources/js/components/episodes/Channels.js b/resources/js/components/episodes/Channels.js index fb10147..f3fbf05 100644 --- a/resources/js/components/episodes/Channels.js +++ b/resources/js/components/episodes/Channels.js @@ -3,16 +3,22 @@ import React from 'react'; import Channel from './Channel'; -const Channels = ({ channels }) => -
- {channels.map(channel => - - )} -
; +const Channels = ({ channels, episode, onEditRestream }) => + channels.map(channel => + + ); Channels.propTypes = { channels: PropTypes.arrayOf(PropTypes.shape({ })), + episode: PropTypes.shape({ + }), + onEditRestream: PropTypes.func, }; export default Channels; diff --git a/resources/js/components/episodes/Item.js b/resources/js/components/episodes/Item.js index 5c69829..71fe49e 100644 --- a/resources/js/components/episodes/Item.js +++ b/resources/js/components/episodes/Item.js @@ -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 }) => { : null} -
+
{hasChannels ? - + : null} {!hasChannels && hasPlayers ? : null} + {onAddRestream && canRestreamEpisode(user, episode) ? +
+ +
+ : null}
{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); diff --git a/resources/js/components/episodes/List.js b/resources/js/components/episodes/List.js index d4e73b5..a28fa48 100644 --- a/resources/js/components/episodes/List.js +++ b/resources/js/components/episodes/List.js @@ -4,7 +4,7 @@ import React from 'react'; import Item from './Item'; -const List = ({ episodes }) => { +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]) =>

{moment(day).format('dddd, L')}

{group.map(episode => - + )}
)} ; @@ -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 index 0000000..747b47e --- /dev/null +++ b/resources/js/components/episodes/RestreamAddForm.js @@ -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
+ + {episode ? <> +
+ {episode.event.title} +
+
+ {t('episodes.startTime', { date: new Date(episode.start) })} +
+
+ {episode.players.map(p => getName(p)).join(', ')} +
+ : null} + + {t('episodes.channel')} + + + {((user && user.channel_crews) || []).map(c => + + )} + + {touched.channel_id && errors.channel_id ? + + {t(errors.channel_id)} + + : null} + +
+ + {onCancel ? + + : null} + + +
; +}; + +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 index 0000000..340b3c3 --- /dev/null +++ b/resources/js/components/episodes/RestreamDialog.js @@ -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 + + + {t('episodes.restreamDialog.title')} + + + {channel ? + + : + + } + ; +}; + +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 index 0000000..e4cbe13 --- /dev/null +++ b/resources/js/components/episodes/RestreamEditForm.js @@ -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 <> + + {channel ? +
+ {channel.title} +
+ : null} + {episode ? <> +
+ {episode.event.title} +
+
+ {t('episodes.startTime', { date: new Date(episode.start) })} +
+
+ {episode.players.map(p => getName(p)).join(', ')} +
+ : null} +
+ + {onRemoveRestream ? + + : null} + {onCancel ? + + : null} + + ; +}; + +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; diff --git a/resources/js/components/pages/Schedule.js b/resources/js/components/pages/Schedule.js index 59e6ac4..10b4b49 100644 --- a/resources/js/components/pages/Schedule.js +++ b/resources/js/components/pages/Schedule.js @@ -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 = () => { {episodes.length ? - + : {t('episodes.empty')} } + ; }; -export default Schedule; +Schedule.propTypes = { + user: PropTypes.shape({ + }), +}; + +export default withUser(Schedule); diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index b1de051..835af24 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -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) => { diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 32cc370..ae1b29e 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 200e988..e499651 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -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', diff --git a/resources/sass/episodes.scss b/resources/sass/episodes.scss index 3e801d4..d4d686b 100644 --- a/resources/sass/episodes.scss +++ b/resources/sass/episodes.scss @@ -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; diff --git a/routes/api.php b/routes/api.php index 8c7e086..7980c13 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); -- 2.39.2