]> git.localhorst.tv Git - alttp.git/commitdiff
episode players editor
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Tue, 29 Jul 2025 10:55:59 +0000 (12:55 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Tue, 29 Jul 2025 10:55:59 +0000 (12:55 +0200)
app/Http/Controllers/EpisodeController.php
app/Models/EpisodePlayer.php
resources/js/components/common/UserSelect.jsx
resources/js/components/episodes/Dialog.jsx
resources/js/components/episodes/Form.jsx [deleted file]
resources/js/components/episodes/Form/EpisodePart.jsx [new file with mode: 0644]
resources/js/components/episodes/Form/PlayerPart.jsx [new file with mode: 0644]
resources/js/components/episodes/Form/index.jsx [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js

index 1458f91d07b5c9b5668bfe6acef75834501fbdb8..159629f560afdd2476c751c8e0b1e74d7658eb94 100644 (file)
@@ -16,15 +16,19 @@ class EpisodeController extends Controller {
 
        public function create(Request $request, Event $event) {
                $this->authorize('addEpisode', $event);
-               $validatedData = $this->validateEpisode($request);
-               $episode = $event->episodes()->create($validatedData);
+               $validatedEpisode = $this->validateEpisode($request);
+               $validatedPlayers = $this->validatePlayers($request);
+               $episode = $event->episodes()->create($validatedEpisode);
+               $this->syncPlayers($episode, $validatedPlayers);
                return $episode->toJson();
        }
 
        public function update(Request $request, Episode $episode) {
                $this->authorize('update', $episode);
-               $validatedData = $this->validateEpisode($request);
-               $episode->update($validatedData);
+               $validatedEpisode = $this->validateEpisode($request);
+               $validatedPlayers = $this->validatePlayers($request);
+               $episode->update($validatedEpisode);
+               $this->syncPlayers($episode, $validatedPlayers);
                return $episode->toJson();
        }
 
@@ -307,6 +311,24 @@ class EpisodeController extends Controller {
                        ->toArray();
        }
 
+       private function syncPlayers(Episode $episode, $validatedData): void {
+               $ids = [];
+               if (isset($validatedData['players'])) {
+                       foreach ($validatedData['players'] as $player) {
+                               if (isset($player['id'])) {
+                                       $updated = $episode->players()->find($player['id']);
+                                       $updated->update($player);
+                                       $ids[] = $player['id'];
+                               } else {
+                                       $created = $episode->players()->create($player);
+                                       $ids[] = $created->id;
+                               }
+                       }
+               }
+               $episode->players()->whereNotIn('id', $ids)->delete();
+               $episode->load(['players', 'players.user']);
+       }
+
        private function validateEpisode(Request $request) {
                return $request->validate([
                        'comment' => 'string',
@@ -317,4 +339,15 @@ class EpisodeController extends Controller {
                ]);
        }
 
+       private function validatePlayers(Request $request) {
+               return $request->validate([
+                       'players' => 'array',
+                       'players.*' => 'array',
+                       'players.*.id' => 'nullable|numeric|exists:App\Models\EpisodePlayer,id',
+                       'players.*.name_override' => 'string',
+                       'players.*.stream_override' => 'string',
+                       'players.*.user_id' => 'nullable|numeric|exists:App\Models\User,id',
+               ]);
+       }
+
 }
index e2f927e33c0c88ff4781cf405baa5fe8820b97cc..705fd1ca32dc40ea521294bd65bdb4f9f4faeed1 100644 (file)
@@ -51,6 +51,12 @@ class EpisodePlayer extends Model
                'user_id' => 'string',
        ];
 
+       protected $fillable = [
+               'name_override',
+               'stream_override',
+               'user_id',
+       ];
+
        protected $hidden = [
                'created_at',
                'ext_id',
index 2889cc0ba763c841b8366d08c774a6c0508a6589..80902a1589096cf91d8dcd364021505c2644f572 100644 (file)
@@ -2,18 +2,26 @@ import axios from 'axios';
 import PropTypes from 'prop-types';
 import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { Button, Form, ListGroup } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
 
 import Icon from './Icon';
 import UserBox from '../users/Box';
 import debounce from '../../helpers/debounce';
 
-const UserSelect = ({ excludeIds = [], name, onChange, value }) => {
+const UserSelect = ({
+       className = '',
+       excludeIds = [],
+       name,
+       onChange,
+       value,
+}) => {
        const [resolved, setResolved] = useState(null);
        const [results, setResults] = useState([]);
        const [search, setSearch] = useState('');
        const [showResults, setShowResults] = useState(false);
 
        const ref = useRef(null);
+       const { t } = useTranslation();
 
        useEffect(() => {
                const handleEventOutside = e => {
@@ -74,8 +82,23 @@ const UserSelect = ({ excludeIds = [], name, onChange, value }) => {
                }
        }, [value]);
 
+       const clsn = React.useMemo(() => {
+               const classNames = [];
+               if (value) {
+                       classNames.push('d-flex');
+                       classNames.push('justify-content-between');
+                       classNames.push(showResults ? 'expanded' : 'collapsed');
+               } else {
+                       classNames.push('model-select');
+               }
+               if (className.indexOf('is-invalid') !== -1) {
+                       classNames.push('is-invalid');
+               }
+               return classNames.join(' ');
+       }, [className, value, showResults]);
+
        if (value) {
-               return <div className="d-flex justify-content-between">
+               return <div className={clsn}>
                        {resolved ? <UserBox discriminator noLink user={resolved} /> : <span>value</span>}
                        <Button
                                onClick={() => onChange({ target: { name, value: null }})}
@@ -86,12 +109,14 @@ const UserSelect = ({ excludeIds = [], name, onChange, value }) => {
                        </Button>
                </div>;
        }
-       return <div className={`model-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+
+       return <div className={clsn} ref={ref}>
                <Form.Control
-                       className="search-input"
+                       className={`search-input ${className}`}
                        name={Math.random().toString(20).substr(2, 10)}
                        onChange={e => setSearch(e.target.value)}
                        onFocus={() => setShowResults(true)}
+                       placeholder={t('users.searchPlaceholder')}
                        type="search"
                        value={search}
                />
@@ -118,6 +143,7 @@ const UserSelect = ({ excludeIds = [], name, onChange, value }) => {
 };
 
 UserSelect.propTypes = {
+       className: PropTypes.string,
        excludeIds: PropTypes.arrayOf(PropTypes.string),
        name: PropTypes.string,
        onChange: PropTypes.func,
index 4fa1915b6c2ab92bc0b2431b906b321997c7616f..ab59c39d48bedcb7728b5e1c12bd11ac3e71869a 100644 (file)
@@ -15,7 +15,7 @@ const Dialog = ({
 }) => {
        const { t } = useTranslation();
 
-       return <Modal onHide={onHide} show={show}>
+       return <Modal onHide={onHide} show={show} size="xl">
                <Modal.Header closeButton>
                        <Modal.Title>
                                {t(episode?.id ? 'episodes.edit' : 'episodes.create')}
diff --git a/resources/js/components/episodes/Form.jsx b/resources/js/components/episodes/Form.jsx
deleted file mode 100644 (file)
index 2542fc0..0000000
+++ /dev/null
@@ -1,185 +0,0 @@
-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);
diff --git a/resources/js/components/episodes/Form/EpisodePart.jsx b/resources/js/components/episodes/Form/EpisodePart.jsx
new file mode 100644 (file)
index 0000000..523214d
--- /dev/null
@@ -0,0 +1,148 @@
+import moment from 'moment';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Form, 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';
+
+const EpisodePart = ({
+       errors,
+       handleBlur,
+       handleChange,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <>
+               <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 || ''}
+                       />
+                       {touched.title && errors.title ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.title)}
+                               </Form.Control.Feedback>
+                       : null}
+               </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>
+       </>;
+};
+
+EpisodePart.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 EpisodePart;
diff --git a/resources/js/components/episodes/Form/PlayerPart.jsx b/resources/js/components/episodes/Form/PlayerPart.jsx
new file mode 100644 (file)
index 0000000..b0c359a
--- /dev/null
@@ -0,0 +1,114 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from '../../common/Icon';
+import UserSelect from '../../common/UserSelect';
+
+const PlayerPart = ({
+       errors,
+       handleBlur,
+       handleChange,
+       index,
+       onRemove,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       return <div className="episode-player-form border-bottom pb-3 mb-3">
+               <div className="d-flex align-items-center justify-content-between">
+                       <div className="h4">
+                               {t('episodes.players.number', { number: index + 1 })}
+                       </div>
+                       <div className="button-bar">
+                               <Button
+                                       onClick={onRemove}
+                                       size="sm"
+                                       title={t('button.remove')}
+                                       variant="outline-danger"
+                               >
+                                       <Icon.REMOVE title="" />
+                               </Button>
+                       </div>
+               </div>
+               <Row>
+                       <Form.Group as={Col} controlId={`episode.players.${index}.user_id`}>
+                               <Form.Label>{t('episodes.players.user')}</Form.Label>
+                               <Form.Control
+                                       as={UserSelect}
+                                       isInvalid={!!(touched.user_id && errors.user_id)}
+                                       name={`players.${index}.user_id`}
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       value={values.user_id || ''}
+                               />
+                               {touched.user_id && errors.user_id ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.user_id)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+                       <Form.Group as={Col} controlId={`episode.players.${index}.name_override}`}>
+                               <Form.Label>{t('episodes.players.name_override')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.name_override && errors.name_override)}
+                                       name={`players.${index}.name_override`}
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       type="text"
+                                       value={values.name_override || ''}
+                               />
+                               {touched.name_override && errors.name_override ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {t(errors.name_override)}
+                                       </Form.Control.Feedback>
+                               : null}
+                       </Form.Group>
+               </Row>
+               <Form.Group controlId={`episode.players.${index}.stream_override}`}>
+                       <Form.Label>{t('episodes.players.stream_override')}</Form.Label>
+                       <Form.Control
+                               isInvalid={!!(touched.stream_override && errors.stream_override)}
+                               name={`players.${index}.stream_override`}
+                               onBlur={handleBlur}
+                               onChange={handleChange}
+                               type="text"
+                               value={values.stream_override || ''}
+                       />
+                       {touched.stream_override && errors.stream_override ?
+                               <Form.Control.Feedback type="invalid">
+                                       {t(errors.stream_override)}
+                               </Form.Control.Feedback>
+                       : null}
+               </Form.Group>
+       </div>;
+};
+
+PlayerPart.propTypes = {
+       errors: PropTypes.shape({
+               id: PropTypes.string,
+               name_override: PropTypes.string,
+               stream_override: PropTypes.string,
+               user_id: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       index: PropTypes.number,
+       onRemove: PropTypes.func,
+       touched: PropTypes.shape({
+               id: PropTypes.bool,
+               name_override: PropTypes.bool,
+               stream_override: PropTypes.bool,
+               user_id: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               id: PropTypes.number,
+               name_override: PropTypes.string,
+               stream_override: PropTypes.string,
+               user_id: PropTypes.string,
+       }),
+};
+
+export default PlayerPart;
diff --git a/resources/js/components/episodes/Form/index.jsx b/resources/js/components/episodes/Form/index.jsx
new file mode 100644 (file)
index 0000000..9db2c2d
--- /dev/null
@@ -0,0 +1,182 @@
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import EpisodePart from './EpisodePart';
+import PlayerPart from './PlayerPart';
+import { formatEstimate, parseEstimate } from '../../../helpers/Episode';
+import yup from '../../../schema/yup';
+
+const getSubArray = (values, index) => (values && values[index]) || [];
+
+const arrayWithout = (arr, index) => {
+       const copy = [...arr];
+       copy.splice(index, 1);
+       return copy;
+};
+
+const EpisodeForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       setFieldValue,
+       touched,
+       values,
+}) => {
+       const { t } = useTranslation();
+
+       const addPlayer = React.useCallback(() => {
+               setFieldValue('players', [
+                       ...values.players,
+                       {
+                               id: null,
+                               name_override: '',
+                               stream_override: '',
+                               user_id: null,
+                       }
+               ]);
+       }, [setFieldValue, values.players]);
+
+       const removePlayer = React.useCallback((index) => {
+               setFieldValue('players', arrayWithout(values.players, index));
+       }, [setFieldValue, values.players])
+
+       return <Form noValidate onSubmit={handleSubmit}>
+               <Row>
+                       <Col className="border-end" xl={6}>
+                               <Modal.Body>
+                                       <EpisodePart
+                                               errors={errors}
+                                               handleBlur={handleBlur}
+                                               handleChange={handleChange}
+                                               touched={touched}
+                                               values={values}
+                                       />
+                               </Modal.Body>
+                       </Col>
+                       <Col xl={6}>
+                               <Modal.Body>
+                                       {values.players.map((player, index) =>
+                                               <PlayerPart key={player.id || `pending-${index}`}
+                                                       errors={getSubArray(errors.players, index)}
+                                                       handleBlur={handleBlur}
+                                                       handleChange={handleChange}
+                                                       index={index}
+                                                       onRemove={() => removePlayer(index)}
+                                                       touched={getSubArray(touched.players, index)}
+                                                       values={player}
+                                               />
+                                       )}
+                                       <div className="button-bar">
+                                               <Button onClick={addPlayer}>
+                                                       {t('episodes.addPlayer')}
+                                               </Button>
+                                       </div>
+                               </Modal.Body>
+                       </Col>
+               </Row>
+               <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,
+               players: PropTypes.arrayOf(PropTypes.shape({
+                       id: PropTypes.string,
+                       name_override: PropTypes.string,
+                       stream_override: PropTypes.string,
+                       user_id: PropTypes.string,
+               })),
+               start: PropTypes.string,
+               title: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       setFieldValue: PropTypes.func,
+       touched: PropTypes.shape({
+               comment: PropTypes.bool,
+               confirmed: PropTypes.bool,
+               estimate: PropTypes.bool,
+               players: PropTypes.arrayOf(PropTypes.shape({
+                       id: PropTypes.bool,
+                       name_override: PropTypes.bool,
+                       stream_override: PropTypes.bool,
+                       user_id: PropTypes.bool,
+               })),
+               start: PropTypes.bool,
+               title: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               comment: PropTypes.string,
+               confirmed: PropTypes.bool,
+               estimate: PropTypes.string,
+               players: PropTypes.arrayOf(PropTypes.shape({
+                       id: PropTypes.number,
+                       name_override: PropTypes.string,
+                       stream_override: PropTypes.string,
+                       user_id: PropTypes.string,
+               })),
+               start: PropTypes.string,
+               title: PropTypes.string,
+       }),
+};
+
+const mapPlayers = (episode) => ((episode && episode.players) || []).map((player) => ({
+       id: player.id || null,
+       name_override: player.name_override || '',
+       stream_override: player.stream_override || '',
+       user_id: player.user_id || null,
+}));
+
+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,
+               players: mapPlayers(episode),
+               start: episode?.start || '',
+               title: episode?.title || '',
+       }),
+       validationSchema: yup.object().shape({
+               comment: yup.string(),
+               confirmed: yup.bool().required(),
+               estimate: yup.string().required().estimate(),
+               players: yup.array().of(yup.object().shape({
+                       id: yup.number().nullable(),
+                       name_override: yup.string(),
+                       stream_override: yup.string(),
+                       user_id: yup.string().nullable(),
+               })),
+               start: yup.string().required().datestr(),
+               title: yup.string(),
+       }),
+})(EpisodeForm);
index 5b08008adb7df9a52a3e21ef75c40669545bdf8d..b3e2dd4ed5891e9110527419f8da0dfb95c5805d 100644 (file)
@@ -204,6 +204,7 @@ export default {
                        userUnsubError: 'Fehler beim Kündigen',
                },
                episodes: {
+                       addPlayer: 'Spieler hinzufügen',
                        addRestream: 'Neuer Restream',
                        applyDialog: {
                                applyError: 'Fehler bei der Anmeldung',
@@ -226,6 +227,12 @@ export default {
                        estimatePreview: '{{ estimate }} Std.',
                        estimatePreviewWithEnd: '{{ estimate }} Std. (endet {{ end, LL LT }} Uhr)',
                        missingStreams: 'Fehlende Runner-Streams',
+                       players: {
+                               name_override: 'Abweichender Name',
+                               number: 'Player #{{ number }}',
+                               stream_override: 'Abweichender Streamlink',
+                               user: 'Verknüpfter User',
+                       },
                        raceroom: 'Raceroom',
                        restreamDialog: {
                                acceptComms: 'Suche Kommentatoren',
@@ -1008,6 +1015,7 @@ export default {
                        participationEmpty: 'Hat noch an keinen Turnieren teilgenommen.',
                        randomQuoteSource: '{{ date, L }}, {{ source }}, {{ result }}',
                        roundRecords: 'Renn-Platzierungen',
+                       searchPlaceholder: 'Suchen…',
                        setNicknameError: 'Konnte Namen nicht speichern',
                        setNicknameSuccess: 'Name geändert',
                        setStreamLinkError: 'Konnte Stream Link nicht speichern',
index 7247360c49fcd11fb7a52907b0dd0d3c28a487c8..5e63ab9d04467cff6544754dd86bf486cc7bdb35 100644 (file)
@@ -204,6 +204,7 @@ export default {
                        userUnsubError: 'Error unsubscribing',
                },
                episodes: {
+                       addPlayer: 'Add player',
                        addRestream: 'Add Restream',
                        applyDialog: {
                                applyError: 'Error signing up',
@@ -226,6 +227,12 @@ export default {
                        estimatePreview: '{{ estimate }}h',
                        estimatePreviewWithEnd: '{{ estimate }}h (ends {{ end, LL LT }})',
                        missingStreams: 'Missing runner streams',
+                       players: {
+                               name_override: 'Name override',
+                               number: 'Player #{{ number }}',
+                               stream_override: 'Streamlink override',
+                               user: 'Connected user',
+                       },
                        raceroom: 'Race room',
                        restreamDialog: {
                                acceptComms: 'Open commentary application',
@@ -1008,6 +1015,7 @@ export default {
                        participationEmpty: 'Has not participated in any tourneys yet.',
                        randomQuoteSource: '{{ date, L }}, {{ source }}, {{ result }}',
                        roundRecords: 'Race records',
+                       searchPlaceholder: 'Search…',
                        setNicknameError: 'Could not save name',
                        setNicknameSuccess: 'Name changed',
                        setStreamLinkError: 'Could not save stream link',