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();
}
->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',
]);
}
+ 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',
+ ]);
+ }
+
}
'user_id' => 'string',
];
+ protected $fillable = [
+ 'name_override',
+ 'stream_override',
+ 'user_id',
+ ];
+
protected $hidden = [
'created_at',
'ext_id',
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 => {
}
}, [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 }})}
</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}
/>
};
UserSelect.propTypes = {
+ className: PropTypes.string,
excludeIds: PropTypes.arrayOf(PropTypes.string),
name: PropTypes.string,
onChange: PropTypes.func,
}) => {
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')}
+++ /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);
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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);
userUnsubError: 'Fehler beim Kündigen',
},
episodes: {
+ addPlayer: 'Spieler hinzufügen',
addRestream: 'Neuer Restream',
applyDialog: {
applyError: 'Fehler bei der Anmeldung',
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',
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',
userUnsubError: 'Error unsubscribing',
},
episodes: {
+ addPlayer: 'Add player',
addRestream: 'Add Restream',
applyDialog: {
applyError: 'Error signing up',
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',
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',