namespace App\Http\Controllers;
use App\Models\Channel;
+use App\Models\Event;
use App\Models\Episode;
use App\Models\EpisodeCrew;
use App\Models\User;
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);
return $episode->toJson();
}
+ private function validateEpisode(Request $request) {
+ return $request->validate([
+ 'comment' => 'string',
+ 'confirmed' => 'boolean',
+ 'estimate' => 'numeric|required',
+ 'start' => 'date|required',
+ 'title' => 'string',
+ ]);
+ }
+
}
'start' => 'datetime',
];
+ protected $fillable = [
+ 'comment',
+ 'confirmed',
+ 'estimate',
+ 'start',
+ 'title',
+ ];
+
protected $hidden = [
'created_at',
'updated_at',
use HasFactory;
+ public function crews() {
+ return $this->hasMany(EventCrew::class);
+ }
+
public function description() {
return $this->belongsTo(Technique::class);
}
--- /dev/null
+<?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',
+ ];
+
+}
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) {
->where('episodes.confirmed', '=', 1);
}
+ public function event_crews() {
+ return $this->hasMany(EventCrew::class);
+ }
+
public function participation() {
return $this->hasMany(Participant::class);
}
* @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);
}
/**
{
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);
+ }
+
}
--- /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.
+ */
+ 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');
+ }
+};
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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);
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();
</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">
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">
{(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">
...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}`;
+};
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) =>
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) => {
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,
});
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);
}, []);
const value = React.useMemo(() => ({
+ onAddEpisode,
onAddRestream,
onApplyRestream,
+ onEditEpisode,
onEditRestream,
}), [
+ onAddEpisode,
onAddRestream,
onApplyRestream,
+ onEditEpisode,
onEditRestream,
]);
onSubmit={onSubmitApplyDialog}
show={showApplyDialog}
/>
+ <EpisodeDialog
+ episode={editEpisode}
+ onHide={onHideEpisodeDialog}
+ onSubmit={onSubmitEpisodeDialog}
+ show={showEpisodeDialog}
+ />
<RestreamDialog
channel={restreamChannel}
editRestream={editRestream}
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: {
},
setup: 'Setup',
sgSignUp: 'SG Anmeldung',
+ start: 'Beginn',
+ startPreview: '{{ date, LL LT [Uhr (UTC]Z[)] }}',
startTime: '{{ date, LL LT }} Uhr',
+ title: 'Bezeichnung',
tracking: 'Tracking',
},
error: {
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: {
},
setup: 'Setup',
sgSignUp: 'SG Signup',
+ start: 'Start',
+ startPreview: '{{ date, LL LT [(UTC]Z[)] }}',
startTime: '{{ date, LL LT }}',
+ title: 'Title',
tracking: 'Tracking',
},
error: {
<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}
+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;
required: 'validation.error.required',
},
string: {
+ datestr: 'validation.error.datestr',
+ estimate: 'validation.error.estimate',
time: 'validation.error.time',
url: 'validation.error.url',
},
+body {
+ color-scheme: dark;
+}
+
#header {
img {
max-height: 2rem;
}
.custom-toggle {
- display: inline-block;
+ display: table;
width: auto;
height: 2.25rem;
min-width: 58px;
*/
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');
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');
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');