From 14dac4de7212f1341b40b909080edf34379715d9 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 25 Jul 2025 15:19:53 +0200 Subject: [PATCH] allow uploading of seed patches --- app/Http/Controllers/RoundController.php | 19 +++++ resources/js/components/common/FileButton.jsx | 84 +++++++++++++++++++ resources/js/components/common/Icon.jsx | 5 +- resources/js/components/rounds/EditForm.jsx | 11 ++- resources/js/components/rounds/SeedForm.jsx | 71 ++++++++-------- resources/js/components/rounds/SeedInput.jsx | 53 ++++++++++++ resources/js/i18n/de.js | 3 + resources/js/i18n/en.js | 3 + routes/api.php | 1 + 9 files changed, 213 insertions(+), 37 deletions(-) create mode 100644 resources/js/components/common/FileButton.jsx create mode 100644 resources/js/components/rounds/SeedInput.jsx diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 050a1cf..06cd7bd 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -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 index 0000000..1a4ec43 --- /dev/null +++ b/resources/js/components/common/FileButton.jsx @@ -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 <> + + + ; +}; + +FileButton.propTypes = { + multiple: PropTypes.bool, + onFiles: PropTypes.func.isRequired, +}; + +export default FileButton; diff --git a/resources/js/components/common/Icon.jsx b/resources/js/components/common/Icon.jsx index ff676c1..35ecd1a 100644 --- a/resources/js/components/common/Icon.jsx +++ b/resources/js/components/common/Icon.jsx @@ -21,7 +21,7 @@ const Icon = ({ @@ -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'); diff --git a/resources/js/components/rounds/EditForm.jsx b/resources/js/components/rounds/EditForm.jsx index ac2cd42..73b310c 100644 --- a/resources/js/components/rounds/EditForm.jsx +++ b/resources/js/components/rounds/EditForm.jsx @@ -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 = ({ {i18n.t('rounds.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, diff --git a/resources/js/components/rounds/SeedForm.jsx b/resources/js/components/rounds/SeedForm.jsx index 3cff560..5fc5858 100644 --- a/resources/js/components/rounds/SeedForm.jsx +++ b/resources/js/components/rounds/SeedForm.jsx @@ -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, -}) => -
- - - - {i18n.t('rounds.seed')} - - {touched.seed && errors.seed ? - - {i18n.t(errors.seed)} - - : null} - - - - - {onCancel ? - + : null} + - : null} - - -
; + + ; +}; 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 index 0000000..c6d4652 --- /dev/null +++ b/resources/js/components/rounds/SeedInput.jsx @@ -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 + + + ; +}; + +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; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index e52bad5..59bad53 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 596b643..fd4bec9 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -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', diff --git a/routes/api.php b/routes/api.php index 90fefbf..c1b28e2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); -- 2.39.5