use App\Models\Round;
use App\Models\Tournament;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
class RoundController extends Controller
{
return $round->toJson();
}
+ public function uploadSeed(Request $request, Round $round) {
+ $this->authorize('update', $round);
+
+ $validatedData = $request->validate([
+ 'patch' => 'required|file|extensions:bps,ips',
+ ]);
+
+ $file = $validatedData['patch'];
+ $hash = Str::random(40);
+ $ext = $file->getClientOriginalExtension();
+ $path = $file->storeAs($round->id, $hash.'.'.$ext, 'alttp-seeds');
+
+ return [
+ 'url' => Storage::disk('alttp-seeds')->url($path),
+ ];
+ }
+
public function lock(Request $request, Round $round) {
$this->authorize('lock', $round);
--- /dev/null
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import Icon from './Icon';
+
+const FileButton = ({
+ multiple = false,
+ onFiles,
+}) => {
+ const [error, setError] = React.useState(null);
+ const [uploading, setUploading] = React.useState(false);
+
+ const { t } = useTranslation();
+
+ const fileRef = React.useRef();
+
+ const onUploadClick = React.useCallback(() => {
+ fileRef.current.click();
+ }, [fileRef]);
+
+ const onFilesReceived = React.useCallback(async (e) => {
+ const { files } = e.target;
+ if (!files.length) {
+ // file select was cancelled
+ return;
+ }
+ setError(null);
+ setUploading(true);
+ try {
+ await onFiles(files);
+ } catch (e) {
+ setError(e);
+ toastr.error(t('general.uploadError', { message: e.message }));
+ console.error(e);
+ } finally {
+ setUploading(false);
+ }
+ }, [t]);
+
+ const title = React.useMemo(() => {
+ if (error) {
+ return t('general.uploadError', { message: error.message});
+ }
+ if (uploading) {
+ return t('general.uploading');
+ }
+ return t('general.upload');
+ }, [error, t, uploading]);
+
+ const variant = React.useMemo(() => {
+ if (error) {
+ return 'outline-warning';
+ }
+ if (uploading) {
+ return 'outline-info';
+ }
+ return 'outline-secondary';
+ }, [error, t, uploading]);
+
+ return <>
+ <input type="file" hidden multiple={multiple} onChange={onFilesReceived} ref={fileRef} value="" />
+ <Button onClick={onUploadClick} title={title} variant={variant}>
+ {uploading ?
+ <Icon.LOADING title="" />
+ : null}
+ {error ?
+ <Icon.ERROR title="" />
+ : null}
+ {!error && !uploading ?
+ <Icon.UPLOAD title="" />
+ : null}
+ </Button>
+ </>;
+};
+
+FileButton.propTypes = {
+ multiple: PropTypes.bool,
+ onFiles: PropTypes.func.isRequired,
+};
+
+export default FileButton;
<FontAwesomeIcon
icon={name}
alt={alt}
- className={name === Icon.LOADING ? `${className} fa-spin` : className}
+ className={name === 'spinner' ? `${className} fa-spin` : className}
size={size}
title={title}
/>
Icon.DELETE = makePreset('DeleteIcon', 'user-xmark');
Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
Icon.EDIT = makePreset('EditIcon', 'edit');
+Icon.ERROR = makePreset('ErrorIcon', 'triangle-exclamation');
Icon.FILTER = makePreset('FilterIcon', 'filter');
Icon.FINISHED = makePreset('FinishedIcon', 'square-check');
Icon.FIRST_PLACE = makePreset('FirstPlaceIcon', 'trophy');
Icon.INVERT = makePreset('InvertIcon', 'circle-half-stroke');
Icon.LANGUAGE = makePreset('LanguageIcon', 'language');
Icon.LOAD = makePreset('LoadIcon', 'upload');
+Icon.LOADING = makePreset('LoadingIcon', 'spinner');
Icon.LOCKED = makePreset('LockedIcon', 'lock');
Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
Icon.MENU = makePreset('MenuIcon', 'bars');
Icon.TWITCH = makePreset('TwitchIcon', ['fab', 'twitch']);
Icon.UNKNOWN = makePreset('UnknownIcon', 'square-question');
Icon.UNLOCKED = makePreset('UnlockedIcon', 'lock-open');
+Icon.UPLOAD = makePreset('UploadIcon', 'upload');
Icon.VIDEO = makePreset('VideoIcon', 'video');
Icon.WARNING = makePreset('WarningIcon', 'triangle-exclamation');
Icon.VOLUME = makePreset('VolumeIcon', 'volume-high');
import toastr from 'toastr';
import SeedCodeInput from './SeedCodeInput';
+import SeedInput from './SeedInput';
import UserSelect from '../common/UserSelect';
import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
import i18n from '../../i18n';
handleChange,
handleSubmit,
onCancel,
+ round,
touched,
values,
}) =>
<Row>
<Form.Group as={Col} controlId="round.seed">
<Form.Label>{i18n.t('rounds.seed')}</Form.Label>
- <Form.Control
- isInvalid={!!(touched.seed && errors.seed)}
+ <SeedInput
+ error={errors.seed}
name="seed"
onBlur={handleBlur}
onChange={handleChange}
- type="text"
+ round={round}
+ touched={touched.seed}
value={values.seed || ''}
/>
{touched.seed && errors.seed ?
handleChange: PropTypes.func,
handleSubmit: PropTypes.func,
onCancel: PropTypes.func,
+ round: PropTypes.shape({
+ }),
touched: PropTypes.shape({
code: PropTypes.arrayOf(PropTypes.bool),
rolled_by: PropTypes.bool,
import { withTranslation } from 'react-i18next';
import toastr from 'toastr';
+import SeedInput from './SeedInput';
import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik';
import i18n from '../../i18n';
import yup from '../../schema/yup';
handleChange,
handleSubmit,
onCancel,
+ round,
touched,
values,
-}) =>
-<Form noValidate onSubmit={handleSubmit}>
- <Modal.Body>
- <Row>
- <Form.Group as={Col} controlId="round.seed">
- <Form.Label>{i18n.t('rounds.seed')}</Form.Label>
- <Form.Control
- isInvalid={!!(touched.seed && errors.seed)}
- name="seed"
- onBlur={handleBlur}
- onChange={handleChange}
- placeholder="https://alttprpatch.synack.live/patcher.html?patch=https://sahasrahbot.s3.amazonaws.com/patch/DR_XXXXXXXXXXX.bps"
- type="text"
- value={values.seed || ''}
- />
- {touched.seed && errors.seed ?
- <Form.Control.Feedback type="invalid">
- {i18n.t(errors.seed)}
- </Form.Control.Feedback>
- : null}
- </Form.Group>
- </Row>
- </Modal.Body>
- <Modal.Footer>
- {onCancel ?
- <Button onClick={onCancel} variant="secondary">
- {i18n.t('button.cancel')}
+}) => {
+ return <Form noValidate onSubmit={handleSubmit}>
+ <Modal.Body>
+ <Row>
+ <Form.Group as={Col} controlId="round.seed">
+ <Form.Label>{i18n.t('rounds.seed')}</Form.Label>
+ <SeedInput
+ error={errors.seed}
+ name="seed"
+ onBlur={handleBlur}
+ onChange={handleChange}
+ round={round}
+ touched={touched.seed}
+ value={values.seed || ''}
+ />
+ {touched.seed && errors.seed ?
+ <Form.Control.Feedback type="invalid">
+ {i18n.t(errors.seed)}
+ </Form.Control.Feedback>
+ : null}
+ </Form.Group>
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ {onCancel ?
+ <Button onClick={onCancel} variant="secondary">
+ {i18n.t('button.cancel')}
+ </Button>
+ : null}
+ <Button type="submit" variant="primary">
+ {i18n.t('button.save')}
</Button>
- : null}
- <Button type="submit" variant="primary">
- {i18n.t('button.save')}
- </Button>
- </Modal.Footer>
-</Form>;
+ </Modal.Footer>
+ </Form>;
+};
SeedForm.propTypes = {
errors: PropTypes.shape({
handleChange: PropTypes.func,
handleSubmit: PropTypes.func,
onCancel: PropTypes.func,
+ round: PropTypes.shape({
+ }),
touched: PropTypes.shape({
seed: PropTypes.bool,
}),
--- /dev/null
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Form, InputGroup } from 'react-bootstrap';
+
+import FileButton from '../common/FileButton';
+
+const SeedInput = ({
+ error,
+ name,
+ onBlur,
+ onChange,
+ round,
+ touched,
+ value,
+}) => {
+ const handleFiles = React.useCallback(async (files) => {
+ const formData = new FormData();
+ formData.append('patch', files[0]);
+ const rsp = await axios.post(`/api/rounds/${round.id}/uploadSeed`, formData, {
+ 'Content-Type': 'multipart/form-data',
+ });
+ const value = `https://alttprpatch.synack.live/patcher.html?patch=${encodeURIComponent(rsp.data.url)}`;
+ onChange({ target: { name, value } });
+ }, [onChange, name, round?.id]);
+
+ return <InputGroup className={(touched && error) ? 'is-invalid' : null}>
+ <Form.Control
+ isInvalid={!!(touched && error)}
+ name={name}
+ onBlur={onBlur}
+ onChange={onChange}
+ placeholder="patcher.html?patch=agapedseed.bps"
+ type="text"
+ value={value || ''}
+ />
+ <FileButton onFiles={handleFiles} />
+ </InputGroup>;
+};
+
+SeedInput.propTypes = {
+ error: PropTypes.string,
+ name: PropTypes.string.isRequired,
+ onBlur: PropTypes.func,
+ onChange: PropTypes.func.isRequired,
+ round: PropTypes.shape({
+ id: PropTypes.number,
+ }),
+ touched: PropTypes.bool,
+ value: PropTypes.string,
+};
+
+export default SeedInput;
resetSuccess: 'Zurückgesetzt',
saveError: 'Fehler beim Speichern',
saveSuccess: 'Gespeichert',
+ upload: 'Datei hochladen',
+ uploadError: 'Fehler beim Hochladen',
+ uploading: 'Am Hochladen...',
},
icon: {
AddIcon: 'Hinzufügen',
resetSuccess: 'Reset successful',
saveError: 'Error saving',
saveSuccess: 'Saved successfully',
+ upload: 'Upload file',
+ uploadError: 'Error uploading',
+ uploading: 'Uploading...',
},
icon: {
AddIcon: 'Add',
Route::post('rounds/{round}/lock', 'App\Http\Controllers\RoundController@lock');
Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed');
Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock');
+Route::post('rounds/{round}/uploadSeed', 'App\Http\Controllers\RoundController@uploadSeed');
Route::get('step-ladder-modes', 'App\Http\Controllers\StepLadderModeController@search');
Route::get('step-ladder-modes/{mode}', 'App\Http\Controllers\StepLadderModeController@single');