]> git.localhorst.tv Git - alttp.git/commitdiff
base episode editing
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 28 Jul 2025 13:06:55 +0000 (15:06 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 28 Jul 2025 13:17:23 +0000 (15:17 +0200)
23 files changed:
app/Http/Controllers/EpisodeController.php
app/Models/Episode.php
app/Models/Event.php
app/Models/EventCrew.php [new file with mode: 0644]
app/Models/User.php
app/Policies/EpisodePolicy.php
app/Policies/EventPolicy.php
database/migrations/2025_07_27_111928_create_event_crews_table.php [new file with mode: 0644]
resources/js/components/common/DateTimeInput.jsx [new file with mode: 0644]
resources/js/components/episodes/Dialog.jsx [new file with mode: 0644]
resources/js/components/episodes/Form.jsx [new file with mode: 0644]
resources/js/components/episodes/Item.jsx
resources/js/components/events/Detail.jsx
resources/js/helpers/Episode.js
resources/js/helpers/permissions.js
resources/js/hooks/episodes.jsx
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/Event.jsx
resources/js/schema/yup.js
resources/sass/common.scss
resources/sass/form.scss
routes/api.php

index 3f484c240be2f02f70a4ea68ff855f48f191d6ae..3e3fd8a7959de67657dec69b7d5d998be920cabc 100644 (file)
@@ -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',
+               ]);
+       }
+
 }
index 6ca4e0b4ac54afcaea291b9c7e672b3237a9a4a7..98330f8f106985ba92f1cd624860a7edc5b01f93 100644 (file)
@@ -177,6 +177,14 @@ class Episode extends Model
                'start' => 'datetime',
        ];
 
+       protected $fillable = [
+               'comment',
+               'confirmed',
+               'estimate',
+               'start',
+               'title',
+       ];
+
        protected $hidden = [
                'created_at',
                'updated_at',
index 8272f49e343b9b0a02188d6ce4d005d3213e098a..f63a558fdde63583880b302c960abda1b90a0138 100644 (file)
@@ -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 (file)
index 0000000..84e2135
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class EventCrew extends Model {
+
+       public function event() {
+               return $this->belongsTo(Event::class);
+       }
+
+       public function user() {
+               return $this->belongsTo(User::class);
+       }
+
+       protected $casts = [
+               'user_id' => 'string',
+       ];
+
+}
index 0fdaf7482e5df4dbaacd6ecb4a6a068e2feb2234..f30dfccde812b5a440731cbb17ac91d146d6c0d1 100644 (file)
@@ -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);
        }
index 1957bbe1f1c704dc9ee12b36c71f469ec1fe6353..1996d21b603dcfd065ce7b7501ec965c4fa7db28 100644 (file)
@@ -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);
        }
 
        /**
index 7acb3b22d890ad49c0efe5098369c6f81587af09..c59c0dfca4b4e48de5a98331a6d946e572c795b5 100644 (file)
@@ -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 (file)
index 0000000..36f1482
--- /dev/null
@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        */
+       public function up(): void
+       {
+               Schema::create('event_crews', function (Blueprint $table) {
+                       $table->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 (file)
index 0000000..47d9ddf
--- /dev/null
@@ -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 <Row className={clsn}>
+               <Col className="date-input" xs={6}>
+                       <Form.Control
+                               className={className}
+                               onChange={handleDateChange}
+                               type="date"
+                               value={value ? moment(value).format('YYYY-MM-DD') : ''}
+                       />
+               </Col>
+               <Col className="time-input" xs={6}>
+                       <Form.Control
+                               className={className}
+                               onChange={handleTimeChange}
+                               type="time"
+                               value={value ? moment(value).format('HH:mm') : ''}
+                       />
+               </Col>
+       </Row>;
+};
+
+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 (file)
index 0000000..4fa1915
--- /dev/null
@@ -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 <Modal onHide={onHide} show={show}>
+               <Modal.Header closeButton>
+                       <Modal.Title>
+                               {t(episode?.id ? 'episodes.edit' : 'episodes.create')}
+                       </Modal.Title>
+               </Modal.Header>
+               <React.Suspense fallback={<Loading />}>
+                       <Form
+                               episode={episode}
+                               onCancel={onHide}
+                               onSubmit={onSubmit}
+                       />
+               </React.Suspense>
+       </Modal>;
+};
+
+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 (file)
index 0000000..2542fc0
--- /dev/null
@@ -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 <Form noValidate onSubmit={handleSubmit}>
+               <Modal.Body>
+                       <Form.Group controlId="episode.title">
+                               <Form.Label>{t('episodes.title')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.title && errors.title)}
+                                       name="title"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.title || ''}
+                               />
+                       </Form.Group>
+                       <Form.Group controlId="episode.start">
+                               <Form.Label>{t('episodes.start')}</Form.Label>
+                               <Form.Control
+                                       as={DateTimeInput}
+                                       isInvalid={!!(touched.start && errors.start)}
+                                       name="start"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.start || ''}
+                               />
+                               {touched.start && errors.start ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.start)}
+                                       </Form.Control.Feedback>
+                               :
+                                       <Form.Text muted>
+                                               {values.start ?
+                                                       t('episodes.startPreview', {
+                                                               date: new Date(values.start),
+                                                       })
+                                               : null}
+                                       </Form.Text>
+                               }
+                       </Form.Group>
+                       <Row>
+                               <Form.Group as={Col} controlId="episode.estimate" xs={8} sm={9}>
+                                       <Form.Label>{t('episodes.estimate')}</Form.Label>
+                                       <Form.Control
+                                               isInvalid={!!(touched.estimate && errors.estimate)}
+                                               name="estimate"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               placeholder={'4:20'}
+                                               type="text"
+                                               value={values.estimate || ''}
+                                       />
+                                       {touched.estimate && errors.estimate ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.estimate)}
+                                               </Form.Control.Feedback>
+                                       :
+                                               <Form.Text muted>
+                                                       {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}
+                                               </Form.Text>
+                                       }
+                               </Form.Group>
+                               <Form.Group as={Col} controlId="episode.confirmed" xs={4} sm={3}>
+                                       <Form.Label>{t('episodes.confirmed')}</Form.Label>
+                                       <Form.Control
+                                               as={ToggleSwitch}
+                                               isInvalid={!!(touched.confirmed && errors.confirmed)}
+                                               name="confirmed"
+                                               onBlur={handleBlur}
+                                               onChange={handleChange}
+                                               value={values.confirmed || false}
+                                       />
+                                       {touched.confirmed && errors.confirmed ?
+                                               <Form.Control.Feedback type="invalid">
+                                                       {t(errors.confirmed)}
+                                               </Form.Control.Feedback>
+                                       : null}
+                               </Form.Group>
+                       </Row>
+                       <Form.Group controlId="episode.comment">
+                               <Form.Label>{t('episodes.comment')}</Form.Label>
+                               <Form.Control
+                                       as="textarea"
+                                       isInvalid={!!(touched.comment && errors.comment)}
+                                       name="comment"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.comment || ''}
+                               />
+                       </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>;
+};
+
+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);
index 7f2214f2f33e1f3c1a7c8787f433019509c36c9c..5b7bd01afb4b7052f1eb7d88f6fe5df6efc010e7 100644 (file)
@@ -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 }) => {
                                                </Button>
                                        </div>
                                : null}
-                               {onAddRestream && canRestreamEpisode(user, episode) ?
-                                       <div>
+                               <div>
+                                       {onEditEpisode && mayEditEpisode(user, episode) ?
+                                               <Button
+                                                       className="ms-1"
+                                                       onClick={() => onEditEpisode(episode)}
+                                                       title={t('episodes.edit')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       <Icon.EDIT title="" />
+                                               </Button>
+                                       : null}
+                                       {onAddRestream && canRestreamEpisode(user, episode) ?
                                                <Button
+                                                       className="ms-1"
                                                        onClick={() => onAddRestream(episode)}
                                                        title={t('episodes.addRestream')}
                                                        variant="outline-secondary"
                                                >
                                                        <Icon.ADD title="" />
                                                </Button>
-                                       </div>
-                               : null}
+                                       : null}
+                               </div>
                        </div>
                </div>
                <div className="episode-body d-flex flex-column flex-fill">
index d89bae1134540429d8b49f9971db9b48440a11fc..98e7d28943a127011a90376ea6eb83b087d55902 100644 (file)
@@ -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 <>
                <div className="d-flex align-items-center justify-content-between">
@@ -18,17 +25,28 @@ const Detail = ({ actions, event }) => {
                                {(event.description && getTranslation(event.description, 'title', i18n.language))
                                        || event.title}
                        </h1>
-                       {event.description && actions.editContent ?
-                               <Button
-                                       className="ms-3"
-                                       onClick={() => actions.editContent(event.description)}
-                                       size="sm"
-                                       title={t('button.edit')}
-                                       variant="outline-secondary"
-                               >
-                                       <Icon.EDIT title="" />
-                               </Button>
-                       : null}
+                       <div className="button-bar">
+                               {event.description && actions.editContent ?
+                                       <Button
+                                               onClick={() => actions.editContent(event.description)}
+                                               size="sm"
+                                               title={t('button.edit')}
+                                               variant="outline-secondary"
+                                       >
+                                               <Icon.EDIT title="" />
+                                       </Button>
+                               : null}
+                               {onAddEpisode && mayAddEpisodes(user, event) ?
+                                       <Button
+                                               onClick={() => onAddEpisode(event)}
+                                               size="sm"
+                                               title={t('button.add')}
+                                               variant="outline-secondary"
+                                       >
+                                               <Icon.ADD title="" />
+                                       </Button>
+                               : null}
+                       </div>
                </div>
                {event.banner ?
                        <div className="event-banner-container">
index c18b68ec9c5e24a3a9e1e130592b56c88613bc8a..6e280646d0926537333cdb4abbc01039d191999f 100644 (file)
@@ -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}`;
+};
index 94f778428f49e705c1f6c673f192da97ffa4d863..df61b3051dec3992346f7c4e66df7cbabef67a7f 100644 (file)
@@ -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) => {
index 4748dcd4535bda359eb41284be14cfd1ecbb971f..1413b66d83f20a59934bd6937942a1729474637f 100644 (file)
@@ -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}
                        />
+                       <EpisodeDialog
+                               episode={editEpisode}
+                               onHide={onHideEpisodeDialog}
+                               onSubmit={onSubmitEpisodeDialog}
+                               show={showEpisodeDialog}
+                       />
                        <RestreamDialog
                                channel={restreamChannel}
                                editRestream={editRestream}
index 59bad535fa0a5b4c78fb9c655ad713c124136cc2..1c3a44a1181085dcd1c7b8c9dc223d530da89736 100644 (file)
@@ -212,8 +212,19 @@ export default {
                                title: 'Anmeldung',
                        },
                        channel: 'Kanal',
+                       comment: 'Anmerkung',
                        commentary: 'Kommentar',
+                       confirmed: 'Bestätigt',
+                       create: 'Neue Episode',
+                       createError: 'Fehler beim Speichern',
+                       createSuccess: 'Episode eingetragen',
+                       edit: 'Episode bearbeiten',
+                       editError: 'Fehler beim Speichern',
+                       editSuccess: 'Änderungen gespeichert',
                        empty: 'Keine anstehenden Termine.',
+                       estimate: 'Geschätzte Laufzeit',
+                       estimatePreview: '{{ estimate }} Std.',
+                       estimatePreviewWithEnd: '{{ estimate }} Std. (endet {{ end, LL LT }} Uhr)',
                        missingStreams: 'Fehlende Runner-Streams',
                        raceroom: 'Raceroom',
                        restreamDialog: {
@@ -232,7 +243,10 @@ export default {
                        },
                        setup: 'Setup',
                        sgSignUp: 'SG Anmeldung',
+                       start: 'Beginn',
+                       startPreview: '{{ date, LL LT [Uhr (UTC]Z[)] }}',
                        startTime: '{{ date, LL LT }} Uhr',
+                       title: 'Bezeichnung',
                        tracking: 'Tracking',
                },
                error: {
index fd4bec954c613e336518f4a732bdc36984ad10f9..d9443c7c474e166242cf1f17fbc7f796963a7e63 100644 (file)
@@ -212,8 +212,19 @@ export default {
                                title: 'Application',
                        },
                        channel: 'Channel',
+                       comment: 'Comment',
                        commentary: 'Commentary',
+                       confirmed: 'Confirmed',
+                       create: 'Add episode',
+                       createError: 'Error saving episode',
+                       createSuccess: 'Episode added',
+                       edit: 'Edit episode',
+                       editError: 'Error saving episode',
+                       editSuccess: 'Saved changes',
                        empty: 'No dates coming up.',
+                       estimate: 'Estimated runtime',
+                       estimatePreview: '{{ estimate }}h',
+                       estimatePreviewWithEnd: '{{ estimate }}h (ends {{ end, LL LT }})',
                        missingStreams: 'Missing runner streams',
                        raceroom: 'Race room',
                        restreamDialog: {
@@ -232,7 +243,10 @@ export default {
                        },
                        setup: 'Setup',
                        sgSignUp: 'SG Signup',
+                       start: 'Start',
+                       startPreview: '{{ date, LL LT [(UTC]Z[)] }}',
                        startTime: '{{ date, LL LT }}',
+                       title: 'Title',
                        tracking: 'Tracking',
                },
                error: {
index 3fda20d7733c46ebbb2a1739ce6ae9e3f2b153d0..dadde47f73dedc0ccb83ae3ef472b66735d2a91a 100644 (file)
@@ -157,38 +157,38 @@ export const Component = () => {
                        <meta property="twitter:image" content={event.banner} />
                </Helmet> : null}
                <CanonicalLinks base={`/events/${event.name}`} />
-               <Container>
-                       <Detail actions={actions} event={event} />
-                       <div className="d-flex align-items-center justify-content-between">
-                               <h2 className="mt-4">
-                                       {t(pastMode || hasConcluded(event)
-                                               ? 'events.pastEpisodes'
-                                               : 'events.upcomingEpisodes'
-                                       )}
-                               </h2>
-                               <div className="button-bar">
-                                       {!hasConcluded(event) ?
-                                               <Button
-                                                       className="ms-3"
-                                                       onClick={() => setPastMode(!pastMode)}
-                                                       title={t(pastMode ? 'events.setFutureMode' : 'events.setPastMode')}
-                                                       variant="outline-secondary"
-                                               >
-                                                       <Icon.TIME_REVERSE title="" />
-                                               </Button>
-                                       : null}
+               <EpisodesProvider setEpisodes={setEpisodes}>
+                       <Container>
+                               <Detail actions={actions} event={event} />
+                               <div className="d-flex align-items-center justify-content-between">
+                                       <h2 className="mt-4">
+                                               {t(pastMode || hasConcluded(event)
+                                                       ? 'events.pastEpisodes'
+                                                       : 'events.upcomingEpisodes'
+                                               )}
+                                       </h2>
+                                       <div className="button-bar">
+                                               {!hasConcluded(event) ?
+                                                       <Button
+                                                               className="ms-3"
+                                                               onClick={() => setPastMode(!pastMode)}
+                                                               title={t(pastMode ? 'events.setFutureMode' : 'events.setPastMode')}
+                                                               variant="outline-secondary"
+                                                       >
+                                                               <Icon.TIME_REVERSE title="" />
+                                                       </Button>
+                                               : null}
+                                       </div>
                                </div>
-                       </div>
-                       {episodes.length ?
-                               <EpisodesProvider setEpisodes={setEpisodes}>
+                               {episodes.length ?
                                        <EpisodeList episodes={episodes} />
-                               </EpisodesProvider>
-                       :
-                               <Alert variant="info">
-                                       {t(pastMode ? 'events.noPastEpisodes' : 'events.noUpcomingEpisodes')}
-                               </Alert>
-                       }
-               </Container>
+                               :
+                                       <Alert variant="info">
+                                               {t(pastMode ? 'events.noPastEpisodes' : 'events.noUpcomingEpisodes')}
+                                       </Alert>
+                               }
+                       </Container>
+               </EpisodesProvider>
                <Dialog
                        content={editContent}
                        language={i18n.language}
index 501d79e01db8bc0e7db0931f44cede0f643680f9..ed10e279b1409b88b34fd24466f924d63dc23fb3 100644 (file)
@@ -1,7 +1,31 @@
+import moment from 'moment';
 import * as yup from 'yup';
 
+import { parseEstimate } from '../helpers/Episode';
 import { parseTime } from '../helpers/Result';
 
+yup.addMethod(yup.string, 'datestr', function (errorMessage) {
+       return this.test('test-datestr-format', errorMessage, function (value) {
+               const { path, createError } = this;
+               return (
+                       !value ||
+                       moment(value).isValid() ||
+                       createError({ path, message: errorMessage || 'validation.error.datestr' })
+               );
+       });
+});
+
+yup.addMethod(yup.string, 'estimate', function (errorMessage) {
+       return this.test('test-estimate-format', errorMessage, function (value) {
+               const { path, createError } = this;
+               return (
+                       !value ||
+                       !isNaN(parseEstimate(value)) ||
+                       createError({ path, message: errorMessage || 'validation.error.estimate' })
+               );
+       });
+});
+
 yup.addMethod(yup.string, 'time', function (errorMessage) {
        return this.test('test-time-format', errorMessage, function (value) {
                const { path, createError } = this;
@@ -19,6 +43,8 @@ yup.setLocale({
                required: 'validation.error.required',
        },
        string: {
+               datestr: 'validation.error.datestr',
+               estimate: 'validation.error.estimate',
                time: 'validation.error.time',
                url: 'validation.error.url',
        },
index bbb4f0f246e67fb1d88958fd9cf869ea36b90ba2..d3a6bfaa1235d5f81aa3e72521dd1c91ef7622ca 100644 (file)
@@ -1,3 +1,7 @@
+body {
+       color-scheme: dark;
+}
+
 #header {
        img {
                max-height: 2rem;
index 6d95d6484efe8dc89651323a33dc28449166f2af..d2008dea48da36ceea516ca07758f4b110263c33 100644 (file)
@@ -14,7 +14,7 @@ label {
 }
 
 .custom-toggle {
-       display: inline-block;
+       display: table;
        width: auto;
        height: 2.25rem;
        min-width: 58px;
index c1b28e2a72df5b4eb3e134ffda54ec2519c5760a..86e4aac2e5e1bf5e0b8593416661b4248de68eed 100644 (file)
@@ -15,7 +15,12 @@ use Illuminate\Support\Facades\Route;
 */
 
 Route::middleware('auth:sanctum')->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');