From: Daniel Karbach Date: Mon, 28 Jul 2025 13:06:55 +0000 (+0200) Subject: base episode editing X-Git-Url: http://git.localhorst.tv/?a=commitdiff_plain;h=8bddbe447f53fd9d05504730743dd13b1ebda0ec;p=alttp.git base episode editing --- diff --git a/app/Http/Controllers/EpisodeController.php b/app/Http/Controllers/EpisodeController.php index 3f484c2..3e3fd8a 100644 --- a/app/Http/Controllers/EpisodeController.php +++ b/app/Http/Controllers/EpisodeController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Channel; +use App\Models\Event; use App\Models\Episode; use App\Models\EpisodeCrew; use App\Models\User; @@ -10,8 +11,21 @@ use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; -class EpisodeController extends Controller -{ +class EpisodeController extends Controller { + + public function create(Request $request, Event $event) { + $this->authorize('addEpisode', $event); + $validatedData = $this->validateEpisode($request); + $episode = $event->episodes()->create($validatedData); + return $episode->toJson(); + } + + public function update(Request $request, Episode $episode) { + $this->authorize('update', $episode); + $validatedData = $this->validateEpisode($request); + $episode->update($validatedData); + return $episode->toJson(); + } public function addRestream(Request $request, Episode $episode) { $this->authorize('addRestream', $episode); @@ -271,4 +285,14 @@ class EpisodeController extends Controller return $episode->toJson(); } + private function validateEpisode(Request $request) { + return $request->validate([ + 'comment' => 'string', + 'confirmed' => 'boolean', + 'estimate' => 'numeric|required', + 'start' => 'date|required', + 'title' => 'string', + ]); + } + } diff --git a/app/Models/Episode.php b/app/Models/Episode.php index 6ca4e0b..98330f8 100644 --- a/app/Models/Episode.php +++ b/app/Models/Episode.php @@ -177,6 +177,14 @@ class Episode extends Model 'start' => 'datetime', ]; + protected $fillable = [ + 'comment', + 'confirmed', + 'estimate', + 'start', + 'title', + ]; + protected $hidden = [ 'created_at', 'updated_at', diff --git a/app/Models/Event.php b/app/Models/Event.php index 8272f49..f63a558 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -10,6 +10,10 @@ class Event extends Model use HasFactory; + public function crews() { + return $this->hasMany(EventCrew::class); + } + public function description() { return $this->belongsTo(Technique::class); } diff --git a/app/Models/EventCrew.php b/app/Models/EventCrew.php new file mode 100644 index 0000000..84e2135 --- /dev/null +++ b/app/Models/EventCrew.php @@ -0,0 +1,21 @@ +belongsTo(Event::class); + } + + public function user() { + return $this->belongsTo(User::class); + } + + protected $casts = [ + 'user_id' => 'string', + ]; + +} diff --git a/app/Models/User.php b/app/Models/User.php index 0fdaf74..f30dfcc 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -57,6 +57,13 @@ class User extends Authenticatable return $this->role === 'special' || $this->isAdmin(); } + public function isEventAdmin(Event $event) { + return $this->event_crews() + ->where('role', '=', 'admin') + ->where('event_id', '=', $event->id) + ->count() > 0; + } + public function isApplicant(Tournament $tournament) { foreach ($tournament->applications as $applicant) { if ($applicant->user_id == $this->id) { @@ -153,6 +160,10 @@ class User extends Authenticatable ->where('episodes.confirmed', '=', 1); } + public function event_crews() { + return $this->hasMany(EventCrew::class); + } + public function participation() { return $this->hasMany(Participant::class); } diff --git a/app/Policies/EpisodePolicy.php b/app/Policies/EpisodePolicy.php index 1957bbe..1996d21 100644 --- a/app/Policies/EpisodePolicy.php +++ b/app/Policies/EpisodePolicy.php @@ -51,9 +51,8 @@ class EpisodePolicy * @param \App\Models\Episode $episode * @return \Illuminate\Auth\Access\Response|bool */ - public function update(User $user, Episode $episode) - { - return $user->isAdmin(); + public function update(User $user, Episode $episode) { + return $user->isEventAdmin($episode->event); } /** diff --git a/app/Policies/EventPolicy.php b/app/Policies/EventPolicy.php index 7acb3b2..c59c0df 100644 --- a/app/Policies/EventPolicy.php +++ b/app/Policies/EventPolicy.php @@ -91,4 +91,16 @@ class EventPolicy { return false; } + + /** + * Determine whether the user can add episodes for the event. + * + * @param \App\Models\User $user + * @param \App\Models\Event $event + * @return \Illuminate\Auth\Access\Response|bool + */ + public function addEpisode(User $user, Event $event) { + return $user->isEventAdmin($event); + } + } diff --git a/database/migrations/2025_07_27_111928_create_event_crews_table.php b/database/migrations/2025_07_27_111928_create_event_crews_table.php new file mode 100644 index 0000000..36f1482 --- /dev/null +++ b/database/migrations/2025_07_27_111928_create_event_crews_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained(); + $table->foreignId('event_id')->constrained(); + $table->string('role')->default('helper'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_crews'); + } +}; diff --git a/resources/js/components/common/DateTimeInput.jsx b/resources/js/components/common/DateTimeInput.jsx new file mode 100644 index 0000000..47d9ddf --- /dev/null +++ b/resources/js/components/common/DateTimeInput.jsx @@ -0,0 +1,65 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Col, Form, Row } from 'react-bootstrap'; + +const DateTimeInput = ({ + className = '', + name = null, + onChange, + value = '', +}) => { + const handleDateChange = React.useCallback(e => { + if (!e.target.value) { + onChange({ target: { name, value: '' } }); + return; + } + const time = value ? moment(value).format('HH:mm') : '00:00'; + const localDate = `${e.target.value}T${time}`; + onChange({ target: { name, value: new Date(localDate).toISOString() } }); + }, [name, onChange, value]); + + const handleTimeChange = React.useCallback(e => { + if (!value) return; + const date = moment(value).format('YYYY-MM-DD'); + const time = e.target.value || '00:00'; + const localDate = `${date}T${time}`; + onChange({ target: { name, value: new Date(localDate).toISOString() } }); + }, [name, onChange, value]); + + const clsn = React.useMemo(() => { + const classNames = ['datetime-input']; + if (className.indexOf('is-invalid') !== -1) { + classNames.push('is-invalid'); + } + return classNames.join(' '); + }, [className]); + + return + + + + + + + ; +}; + +DateTimeInput.propTypes = { + className: PropTypes.string, + name: PropTypes.string, + onChange: PropTypes.func.isRequired, + value: PropTypes.string, +}; + +export default DateTimeInput; diff --git a/resources/js/components/episodes/Dialog.jsx b/resources/js/components/episodes/Dialog.jsx new file mode 100644 index 0000000..4fa1915 --- /dev/null +++ b/resources/js/components/episodes/Dialog.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import Loading from '../common/Loading'; + +const Form = React.lazy(() => import('./Form')); + +const Dialog = ({ + episode, + onHide, + onSubmit, + show, +}) => { + const { t } = useTranslation(); + + return + + + {t(episode?.id ? 'episodes.edit' : 'episodes.create')} + + + }> +
+ + ; +}; + +Dialog.propTypes = { + episode: PropTypes.shape({ + id: PropTypes.number, + }), + onHide: PropTypes.func, + onSubmit: PropTypes.func, + show: PropTypes.bool, +}; + +export default Dialog; diff --git a/resources/js/components/episodes/Form.jsx b/resources/js/components/episodes/Form.jsx new file mode 100644 index 0000000..2542fc0 --- /dev/null +++ b/resources/js/components/episodes/Form.jsx @@ -0,0 +1,185 @@ +import { withFormik } from 'formik'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import DateTimeInput from '../common/DateTimeInput'; +import ToggleSwitch from '../common/ToggleSwitch'; +import { formatEstimate, parseEstimate } from '../../helpers/Episode'; +import yup from '../../schema/yup'; + +const EpisodeForm = ({ + errors, + handleBlur, + handleChange, + handleSubmit, + onCancel, + touched, + values, +}) => { + const { t } = useTranslation(); + + return + + + {t('episodes.title')} + + + + {t('episodes.start')} + + {touched.start && errors.start ? + + {t(errors.start)} + + : + + {values.start ? + t('episodes.startPreview', { + date: new Date(values.start), + }) + : null} + + } + + + + {t('episodes.estimate')} + + {touched.estimate && errors.estimate ? + + {t(errors.estimate)} + + : + + {parseEstimate(values.estimate) ? + t(values.start ? 'episodes.estimatePreviewWithEnd' : 'episodes.estimatePreview', { + estimate: formatEstimate({ estimate: parseEstimate(values.estimate) }), + end: moment(values.start).add(parseEstimate(values.estimate), 'seconds').toDate(), + }) + : null} + + } + + + {t('episodes.confirmed')} + + {touched.confirmed && errors.confirmed ? + + {t(errors.confirmed)} + + : null} + + + + {t('episodes.comment')} + + + + + {onCancel ? + + : null} + + +
; +}; + +EpisodeForm.propTypes = { + errors: PropTypes.shape({ + comment: PropTypes.string, + confirmed: PropTypes.string, + estimate: PropTypes.string, + start: PropTypes.string, + title: PropTypes.string, + }), + handleBlur: PropTypes.func, + handleChange: PropTypes.func, + handleSubmit: PropTypes.func, + onCancel: PropTypes.func, + touched: PropTypes.shape({ + comment: PropTypes.bool, + confirmed: PropTypes.bool, + estimate: PropTypes.bool, + start: PropTypes.bool, + title: PropTypes.bool, + }), + values: PropTypes.shape({ + comment: PropTypes.string, + confirmed: PropTypes.bool, + estimate: PropTypes.string, + start: PropTypes.string, + title: PropTypes.string, + }), +}; + +export default withFormik({ + displayName: 'EpisodeForm', + enableReinitialize: true, + handleSubmit: async (values, actions) => { + const { onSubmit } = actions.props; + await onSubmit({ + ...values, + estimate: parseEstimate(values.estimate), + }); + }, + mapPropsToValues: ({ episode, event }) => ({ + comment: episode?.comment || '', + confirmed: episode?.confirmed || false, + estimate: episode?.estimate ? formatEstimate(episode) : '', + event_id: episode?.event_id || event?.id || null, + id: episode?.id || null, + start: episode?.start || '', + title: episode?.title || '', + }), + validationSchema: yup.object().shape({ + comment: yup.string(), + confirmed: yup.bool().required(), + estimate: yup.string().required().estimate(), + start: yup.string().required().datestr(), + title: yup.string(), + }), +})(EpisodeForm); diff --git a/resources/js/components/episodes/Item.jsx b/resources/js/components/episodes/Item.jsx index 7f2214f..5b7bd01 100644 --- a/resources/js/components/episodes/Item.jsx +++ b/resources/js/components/episodes/Item.jsx @@ -11,12 +11,16 @@ import Players from './Players'; import Icon from '../common/Icon'; import { hasPassed, hasSGRestream, isActive } from '../../helpers/Episode'; import { getLink } from '../../helpers/Event'; -import { canApplyForEpisode, canRestreamEpisode } from '../../helpers/permissions'; +import { + canApplyForEpisode, + canRestreamEpisode, + mayEditEpisode, +} from '../../helpers/permissions'; import { useEpisodes } from '../../hooks/episodes'; import { useUser } from '../../hooks/user'; const Item = ({ episode }) => { - const { onAddRestream, onEditRestream } = useEpisodes(); + const { onAddRestream, onEditEpisode, onEditRestream } = useEpisodes(); const { t } = useTranslation(); const { user } = useUser(); @@ -85,17 +89,28 @@ const Item = ({ episode }) => { : null} - {onAddRestream && canRestreamEpisode(user, episode) ? -
+
+ {onEditEpisode && mayEditEpisode(user, episode) ? + + : null} + {onAddRestream && canRestreamEpisode(user, episode) ? -
- : null} + : null} +
diff --git a/resources/js/components/events/Detail.jsx b/resources/js/components/events/Detail.jsx index d89bae1..98e7d28 100644 --- a/resources/js/components/events/Detail.jsx +++ b/resources/js/components/events/Detail.jsx @@ -7,10 +7,17 @@ import Icon from '../common/Icon'; import RawHTML from '../common/RawHTML'; import { hasConcluded } from '../../helpers/Event'; import { getTranslation } from '../../helpers/Technique'; +import { + mayAddEpisodes, +} from '../../helpers/permissions'; +import { useEpisodes } from '../../hooks/episodes'; +import { useUser } from '../../hooks/user'; import i18n from '../../i18n'; const Detail = ({ actions, event }) => { + const { onAddEpisode } = useEpisodes(); const { t } = useTranslation(); + const { user } = useUser(); return <>
@@ -18,17 +25,28 @@ const Detail = ({ actions, event }) => { {(event.description && getTranslation(event.description, 'title', i18n.language)) || event.title} - {event.description && actions.editContent ? - - : null} +
+ {event.description && actions.editContent ? + + : null} + {onAddEpisode && mayAddEpisodes(user, event) ? + + : null} +
{event.banner ?
diff --git a/resources/js/helpers/Episode.js b/resources/js/helpers/Episode.js index c18b68e..6e28064 100644 --- a/resources/js/helpers/Episode.js +++ b/resources/js/helpers/Episode.js @@ -178,3 +178,31 @@ export const getFilterParams = (filter) => { ...filter, }; }; + +export const parseEstimate = str => { + if (!str) return null; + const parts = `${str}`.trim().split(/[-.: ]+/); + if (parts.length == 1) { + return 3600 * +parts[0]; + } + if (parts.length == 2) { + return 3600 * +parts[0] + 60 * +parts[1]; + } + return parts.reduce((acc,time) => (60 * acc) + +time, 0); +}; + +export const formatEstimate = episode => { + const hours = `${Math.floor(episode.estimate / 60 / 60)}`; + let minutes = `${Math.floor((episode.estimate / 60) % 60)}`; + let seconds = `${Math.floor(episode.estimate % 60)}`; + while (minutes.length < 2) { + minutes = `0${minutes}`; + } + if (seconds === '0') { + return `${hours}:${minutes}`; + } + while (seconds.length < 2) { + seconds = `0${seconds}`; + } + return `${hours}:${minutes}:${seconds}`; +}; diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 94f7784..df61b30 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -54,6 +54,10 @@ export const isTracker = (user, episode) => { export const episodeHasChannel = (episode, channel) => episode && channel && episode.channels && episode.channels.find(c => c.id === channel.id); +export const mayEditEpisode = (user, episode) => + user && episode && !episode.ext_id && user.event_crews && + user.event_crews.find(c => c.role === 'admin' && c.event_id === episode.event_id); + export const mayRestreamEpisodes = user => isAnyChannelAdmin(user); export const mayEditRestream = (user, episode, channel) => @@ -89,6 +93,14 @@ export const canRestreamEpisode = (user, episode) => { return remaining_channels.length > 0; }; +// Events + +export const isEventAdmin = (user, event) => + user && event && user.event_crews && + user.event_crews.find(c => c.role === 'admin' && c.event_id === event.id); + +export const mayAddEpisodes = (user, event) => isEventAdmin(user, event); + // Tournaments export const isApplicant = (user, tournament) => { diff --git a/resources/js/hooks/episodes.jsx b/resources/js/hooks/episodes.jsx index 4748dcd..1413b66 100644 --- a/resources/js/hooks/episodes.jsx +++ b/resources/js/hooks/episodes.jsx @@ -6,11 +6,14 @@ import toastr from 'toastr'; import { useUser } from './user'; import ApplyDialog from '../components/episodes/ApplyDialog'; +import EpisodeDialog from '../components/episodes/Dialog'; import RestreamDialog from '../components/episodes/RestreamDialog'; const context = React.createContext({ + onAddEpisode: null, onAddRestream: null, onApplyRestream: null, + onEditEpisode: null, onEditRestream: null, }); @@ -18,14 +21,54 @@ export const useEpisodes = () => React.useContext(context); export const EpisodesProvider = ({ children, setEpisodes }) => { const [applyAs, setApplyAs] = React.useState('commentary'); + const [editEpisode, setEditEpisode] = React.useState(null); const [restreamChannel, setRestreamChannel] = React.useState(null); const [restreamEpisode, setRestreamEpisode] = React.useState(null); const [showApplyDialog, setShowApplyDialog] = React.useState(false); + const [showEpisodeDialog, setShowEpisodeDialog] = React.useState(false); const [showRestreamDialog, setShowRestreamDialog] = React.useState(false); const { t } = useTranslation(); const { user } = useUser(); + const onAddEpisode = React.useCallback((event) => { + setEditEpisode({ event_id: event.id }); + setShowEpisodeDialog(true); + }, []); + + const onEditEpisode = React.useCallback((episode) => { + setEditEpisode(episode); + setShowEpisodeDialog(true); + }, []); + + const onSubmitEpisodeDialog = React.useCallback(async values => { + try { + const response = values.id + ? await axios.put(`/api/episodes/${values.id}`, values) + : await axios.post(`/api/events/${values.event_id}/add-episode`, values); + const newEpisode = response.data; + setEpisodes(episodes => episodes.map(episode => + episode.id === newEpisode.id ? { + ...episode, + ...newEpisode, + } : episode + )); + setEditEpisode(episode => ({ + ...episode, + ...newEpisode, + })); + setShowEpisodeDialog(false); + toastr.success(t(values.id ? 'episodes.editSuccess' : 'episodes.createSuccess')); + } catch (error) { + toastr.error(t(values.id ? 'episodes.editError' : 'episodes.editError', { error })); + } + }, []); + + const onHideEpisodeDialog = React.useCallback(() => { + setShowEpisodeDialog(false); + setEditEpisode(null); + }, []); + const onAddRestream = React.useCallback(episode => { setRestreamEpisode(episode); setShowRestreamDialog(true); @@ -167,12 +210,16 @@ export const EpisodesProvider = ({ children, setEpisodes }) => { }, []); const value = React.useMemo(() => ({ + onAddEpisode, onAddRestream, onApplyRestream, + onEditEpisode, onEditRestream, }), [ + onAddEpisode, onAddRestream, onApplyRestream, + onEditEpisode, onEditRestream, ]); @@ -186,6 +233,12 @@ export const EpisodesProvider = ({ children, setEpisodes }) => { onSubmit={onSubmitApplyDialog} show={showApplyDialog} /> + { : null} - - -
-

- {t(pastMode || hasConcluded(event) - ? 'events.pastEpisodes' - : 'events.upcomingEpisodes' - )} -

-
- {!hasConcluded(event) ? - - : null} + + + +
+

+ {t(pastMode || hasConcluded(event) + ? 'events.pastEpisodes' + : 'events.upcomingEpisodes' + )} +

+
+ {!hasConcluded(event) ? + + : null} +
-
- {episodes.length ? - + {episodes.length ? - - : - - {t(pastMode ? 'events.noPastEpisodes' : 'events.noUpcomingEpisodes')} - - } - + : + + {t(pastMode ? 'events.noPastEpisodes' : 'events.noUpcomingEpisodes')} + + } + + get('/user', function (Request $request) { - return $request->user()->load(['channel_crews', 'channel_crews.channel']); + return $request->user()->load([ + 'channel_crews', + 'channel_crews.channel', + 'event_crews', + 'event_crews.event', + ]); }); Route::get('alttp-seed/{hash}', 'App\Http\Controllers\AlttpSeedController@byHash'); @@ -59,6 +64,8 @@ Route::get('discord-guilds/{guild_id}/subscriptions', 'App\Http\Controllers\Disc Route::post('discord-guilds/{guild_id}/subscriptions', 'App\Http\Controllers\DiscordGuildController@manageSubscriptions'); Route::get('episodes', 'App\Http\Controllers\EpisodeController@search'); +Route::put('episodes/{episode}', 'App\Http\Controllers\EpisodeController@update'); +Route::delete('episodes/{episode}', 'App\Http\Controllers\EpisodeController@delete'); 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'); @@ -67,6 +74,7 @@ Route::post('episodes/{episode}/remove-restream', 'App\Http\Controllers\EpisodeC Route::get('events', 'App\Http\Controllers\EventController@search'); Route::get('events/{event:name}', 'App\Http\Controllers\EventController@single'); +Route::post('events/{event}/add-episode', 'App\Http\Controllers\EpisodeController@create'); Route::get('markers/{map}', 'App\Http\Controllers\TechniqueController@forMap');