From: Daniel Karbach Date: Tue, 29 Jul 2025 10:55:59 +0000 (+0200) Subject: episode players editor X-Git-Url: http://git.localhorst.tv/?a=commitdiff_plain;h=deda76f344cb840e4c3bc68db491bd55f7f9a290;p=alttp.git episode players editor --- diff --git a/app/Http/Controllers/EpisodeController.php b/app/Http/Controllers/EpisodeController.php index 1458f91..159629f 100644 --- a/app/Http/Controllers/EpisodeController.php +++ b/app/Http/Controllers/EpisodeController.php @@ -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', + ]); + } + } diff --git a/app/Models/EpisodePlayer.php b/app/Models/EpisodePlayer.php index e2f927e..705fd1c 100644 --- a/app/Models/EpisodePlayer.php +++ b/app/Models/EpisodePlayer.php @@ -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', diff --git a/resources/js/components/common/UserSelect.jsx b/resources/js/components/common/UserSelect.jsx index 2889cc0..80902a1 100644 --- a/resources/js/components/common/UserSelect.jsx +++ b/resources/js/components/common/UserSelect.jsx @@ -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
+ return
{resolved ? : value}
; } - return
+ + return
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, diff --git a/resources/js/components/episodes/Dialog.jsx b/resources/js/components/episodes/Dialog.jsx index 4fa1915..ab59c39 100644 --- a/resources/js/components/episodes/Dialog.jsx +++ b/resources/js/components/episodes/Dialog.jsx @@ -15,7 +15,7 @@ const Dialog = ({ }) => { const { t } = useTranslation(); - return + return {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 index 2542fc0..0000000 --- a/resources/js/components/episodes/Form.jsx +++ /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
- - - {t('episodes.title')} - - - - {t('episodes.start')} - - {touched.start && errors.start ? - - {t(errors.start)} - - : - - {values.start ? - t('episodes.startPreview', { - date: new Date(values.start), - }) - : null} - - } - - - - {t('episodes.estimate')} - - {touched.estimate && errors.estimate ? - - {t(errors.estimate)} - - : - - {parseEstimate(values.estimate) ? - t(values.start ? 'episodes.estimatePreviewWithEnd' : 'episodes.estimatePreview', { - estimate: formatEstimate({ estimate: parseEstimate(values.estimate) }), - end: moment(values.start).add(parseEstimate(values.estimate), 'seconds').toDate(), - }) - : null} - - } - - - {t('episodes.confirmed')} - - {touched.confirmed && errors.confirmed ? - - {t(errors.confirmed)} - - : null} - - - - {t('episodes.comment')} - - - - - {onCancel ? - - : null} - - -
; -}; - -EpisodeForm.propTypes = { - errors: PropTypes.shape({ - comment: PropTypes.string, - confirmed: PropTypes.string, - estimate: PropTypes.string, - start: PropTypes.string, - title: PropTypes.string, - }), - handleBlur: PropTypes.func, - handleChange: PropTypes.func, - handleSubmit: PropTypes.func, - onCancel: PropTypes.func, - touched: PropTypes.shape({ - comment: PropTypes.bool, - confirmed: PropTypes.bool, - estimate: PropTypes.bool, - start: PropTypes.bool, - title: PropTypes.bool, - }), - values: PropTypes.shape({ - comment: PropTypes.string, - confirmed: PropTypes.bool, - estimate: PropTypes.string, - start: PropTypes.string, - title: PropTypes.string, - }), -}; - -export default withFormik({ - displayName: 'EpisodeForm', - enableReinitialize: true, - handleSubmit: async (values, actions) => { - const { onSubmit } = actions.props; - await onSubmit({ - ...values, - estimate: parseEstimate(values.estimate), - }); - }, - mapPropsToValues: ({ episode, event }) => ({ - comment: episode?.comment || '', - confirmed: episode?.confirmed || false, - estimate: episode?.estimate ? formatEstimate(episode) : '', - event_id: episode?.event_id || event?.id || null, - id: episode?.id || null, - start: episode?.start || '', - title: episode?.title || '', - }), - validationSchema: yup.object().shape({ - comment: yup.string(), - confirmed: yup.bool().required(), - estimate: yup.string().required().estimate(), - start: yup.string().required().datestr(), - title: yup.string(), - }), -})(EpisodeForm); diff --git a/resources/js/components/episodes/Form/EpisodePart.jsx b/resources/js/components/episodes/Form/EpisodePart.jsx new file mode 100644 index 0000000..523214d --- /dev/null +++ b/resources/js/components/episodes/Form/EpisodePart.jsx @@ -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 <> + + {t('episodes.title')} + + {touched.title && errors.title ? + + {t(errors.title)} + + : null} + + + {t('episodes.start')} + + {touched.start && errors.start ? + + {t(errors.start)} + + : + + {values.start ? + t('episodes.startPreview', { + date: new Date(values.start), + }) + : null} + + } + + + + {t('episodes.estimate')} + + {touched.estimate && errors.estimate ? + + {t(errors.estimate)} + + : + + {parseEstimate(values.estimate) ? + t(values.start ? 'episodes.estimatePreviewWithEnd' : 'episodes.estimatePreview', { + estimate: formatEstimate({ estimate: parseEstimate(values.estimate) }), + end: moment(values.start).add(parseEstimate(values.estimate), 'seconds').toDate(), + }) + : null} + + } + + + {t('episodes.confirmed')} + + {touched.confirmed && errors.confirmed ? + + {t(errors.confirmed)} + + : null} + + + + {t('episodes.comment')} + + + ; +}; + +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 index 0000000..b0c359a --- /dev/null +++ b/resources/js/components/episodes/Form/PlayerPart.jsx @@ -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
+
+
+ {t('episodes.players.number', { number: index + 1 })} +
+
+ +
+
+ + + {t('episodes.players.user')} + + {touched.user_id && errors.user_id ? + + {t(errors.user_id)} + + : null} + + + {t('episodes.players.name_override')} + + {touched.name_override && errors.name_override ? + + {t(errors.name_override)} + + : null} + + + + {t('episodes.players.stream_override')} + + {touched.stream_override && errors.stream_override ? + + {t(errors.stream_override)} + + : null} + +
; +}; + +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 index 0000000..9db2c2d --- /dev/null +++ b/resources/js/components/episodes/Form/index.jsx @@ -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
+ + + + + + + + + {values.players.map((player, index) => + removePlayer(index)} + touched={getSubArray(touched.players, index)} + values={player} + /> + )} +
+ +
+
+ +
+ + {onCancel ? + + : null} + + +
; +}; + +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); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 5b08008..b3e2dd4 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 7247360..5e63ab9 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -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',