]> git.localhorst.tv Git - alttp.git/commitdiff
allow uploading of seed patches
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 25 Jul 2025 13:19:53 +0000 (15:19 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 25 Jul 2025 13:19:53 +0000 (15:19 +0200)
app/Http/Controllers/RoundController.php
resources/js/components/common/FileButton.jsx [new file with mode: 0644]
resources/js/components/common/Icon.jsx
resources/js/components/rounds/EditForm.jsx
resources/js/components/rounds/SeedForm.jsx
resources/js/components/rounds/SeedInput.jsx [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
routes/api.php

index 050a1cf3998e81a6b5000cbddc9b6f522d50b9e0..06cd7bdb25b6d489c919848cbf860885231a187d 100644 (file)
@@ -8,6 +8,8 @@ use App\Models\Protocol;
 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
 {
@@ -109,6 +111,23 @@ 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);
 
diff --git a/resources/js/components/common/FileButton.jsx b/resources/js/components/common/FileButton.jsx
new file mode 100644 (file)
index 0000000..1a4ec43
--- /dev/null
@@ -0,0 +1,84 @@
+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;
index ff676c1ffa795fda36f298b2dc91cdf883f9a416..35ecd1af0391b71d113678714c2a7d2ce33d4a4a 100644 (file)
@@ -21,7 +21,7 @@ const Icon = ({
        <FontAwesomeIcon
                icon={name}
                alt={alt}
-               className={name === Icon.LOADING ? `${className} fa-spin` : className}
+               className={name === 'spinner' ? `${className} fa-spin` : className}
                size={size}
                title={title}
        />
@@ -61,6 +61,7 @@ Icon.CROSSHAIRS = makePreset('CrosshairsIcon', 'crosshairs');
 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');
@@ -71,6 +72,7 @@ Icon.INFO = makePreset('Info', 'circle-info');
 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');
@@ -101,6 +103,7 @@ Icon.TIME_REVERSE = makePreset('TimeReverseIcon', 'clock-rotate-left');
 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');
index ac2cd427628217a14f29478570c9d8dd1793c952..73b310c70ca7843bdb93f3f651dff4a5fea43bff 100644 (file)
@@ -7,6 +7,7 @@ import { withTranslation } from 'react-i18next';
 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';
@@ -18,6 +19,7 @@ const EditForm = ({
        handleChange,
        handleSubmit,
        onCancel,
+       round,
        touched,
        values,
 }) =>
@@ -44,12 +46,13 @@ const EditForm = ({
                <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 ?
@@ -139,6 +142,8 @@ EditForm.propTypes = {
        handleChange: PropTypes.func,
        handleSubmit: PropTypes.func,
        onCancel: PropTypes.func,
+       round: PropTypes.shape({
+       }),
        touched: PropTypes.shape({
                code: PropTypes.arrayOf(PropTypes.bool),
                rolled_by: PropTypes.bool,
index 3cff560e93760b0898c784475c254b1fa7d2d236..5fc58581ba13a29b810bc83e03af73622574719a 100644 (file)
@@ -6,6 +6,7 @@ import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
 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';
@@ -16,42 +17,44 @@ const SeedForm = ({
        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({
@@ -61,6 +64,8 @@ SeedForm.propTypes = {
        handleChange: PropTypes.func,
        handleSubmit: PropTypes.func,
        onCancel: PropTypes.func,
+       round: PropTypes.shape({
+       }),
        touched: PropTypes.shape({
                seed: PropTypes.bool,
        }),
diff --git a/resources/js/components/rounds/SeedInput.jsx b/resources/js/components/rounds/SeedInput.jsx
new file mode 100644 (file)
index 0000000..c6d4652
--- /dev/null
@@ -0,0 +1,53 @@
+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;
index e52bad544e94c4d323416acfe3d0166d5b00a04c..59bad535fa0a5b4c78fb9c655ad713c124136cc2 100644 (file)
@@ -329,6 +329,9 @@ export default {
                        resetSuccess: 'Zurückgesetzt',
                        saveError: 'Fehler beim Speichern',
                        saveSuccess: 'Gespeichert',
+                       upload: 'Datei hochladen',
+                       uploadError: 'Fehler beim Hochladen',
+                       uploading: 'Am Hochladen...',
                },
                icon: {
                        AddIcon: 'Hinzufügen',
index 596b643ef403662e2eccd9c1c77895ccc4fde6c7..fd4bec954c613e336518f4a732bdc36984ad10f9 100644 (file)
@@ -329,6 +329,9 @@ export default {
                        resetSuccess: 'Reset successful',
                        saveError: 'Error saving',
                        saveSuccess: 'Saved successfully',
+                       upload: 'Upload file',
+                       uploadError: 'Error uploading',
+                       uploading: 'Uploading...',
                },
                icon: {
                        AddIcon: 'Add',
index 90fefbf950c5c8927846f0825908bf34efef317e..c1b28e2a72df5b4eb3e134ffda54ec2519c5760a 100644 (file)
@@ -84,6 +84,7 @@ Route::delete('rounds/{round}', 'App\Http\Controllers\RoundController@delete');
 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');