From: Daniel Karbach <daniel.karbach@localhorst.tv>
Date: Sat, 12 Mar 2022 22:35:41 +0000 (+0100)
Subject: allow setting seeds
X-Git-Url: https://git.localhorst.tv/?a=commitdiff_plain;h=a907ef7c6676fef11f42933b2d79bdd496b20122;p=alttp.git

allow setting seeds
---

diff --git a/app/Events/RoundChanged.php b/app/Events/RoundChanged.php
new file mode 100644
index 0000000..91d641b
--- /dev/null
+++ b/app/Events/RoundChanged.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Events;
+
+use App\Models\Round;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class RoundChanged implements ShouldBroadcast
+{
+	use Dispatchable, InteractsWithSockets, SerializesModels;
+
+	/**
+	 * Create a new event instance.
+	 *
+	 * @return void
+	 */
+	public function __construct(Round $round)
+	{
+		$this->round = $round;
+	}
+
+	/**
+	 * Get the channels the event should broadcast on.
+	 *
+	 * @return \Illuminate\Broadcasting\Channel|array
+	 */
+	public function broadcastOn()
+	{
+		return new PrivateChannel('Tournament.'.$this->round->tournament_id);
+	}
+
+	public $round;
+
+}
diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php
index 1488d97..d884d54 100644
--- a/app/Http/Controllers/RoundController.php
+++ b/app/Http/Controllers/RoundController.php
@@ -3,6 +3,7 @@
 namespace App\Http\Controllers;
 
 use App\Events\RoundAdded;
+use App\Events\RoundChanged;
 use App\Models\Protocol;
 use App\Models\Round;
 use App\Models\Tournament;
@@ -33,4 +34,27 @@ class RoundController extends Controller
 		return $round->toJson();
 	}
 
+	public function setSeed(Request $request, Round $round) {
+		$this->authorize('setSeed', $round);
+
+		$validatedData = $request->validate([
+			'seed' => 'required|url',
+		]);
+
+		$round->seed = $validatedData['seed'];
+		$round->update();
+
+		Protocol::roundSeedSet(
+			$round->tournament,
+			$round,
+			$request->user(),
+		);
+
+		RoundChanged::dispatch($round);
+
+		$round->load('results');
+
+		return $round->toJson();
+	}
+
 }
diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php
index e182c08..8adc545 100644
--- a/app/Models/Protocol.php
+++ b/app/Models/Protocol.php
@@ -36,6 +36,19 @@ class Protocol extends Model
 		ProtocolAdded::dispatch($protocol);
 	}
 
+	public static function roundSeedSet(Tournament $tournament, Round $round, User $user) {
+		$protocol = static::create([
+			'tournament_id' => $tournament->id,
+			'user_id' => $user->id,
+			'type' => 'round.create',
+			'details' => [
+				'tournament' => static::tournamentMemo($tournament),
+				'round' => static::roundMemo($round),
+			],
+		]);
+		ProtocolAdded::dispatch($protocol);
+	}
+
 	public static function tournamentCreated(Tournament $tournament, User $user) {
 		$protocol = static::create([
 			'tournament_id' => $tournament->id,
diff --git a/app/Policies/RoundPolicy.php b/app/Policies/RoundPolicy.php
index 045785d..1e29803 100644
--- a/app/Policies/RoundPolicy.php
+++ b/app/Policies/RoundPolicy.php
@@ -91,4 +91,16 @@ class RoundPolicy
 	{
 		return false;
 	}
+
+	/**
+	 * Determine whether the user can set the seed for this round.
+	 *
+	 * @param  \App\Models\User  $user
+	 * @param  \App\Models\Round  $round
+	 * @return \Illuminate\Auth\Access\Response|bool
+	 */
+	public function setSeed(User $user, Round $round)
+	{
+		return $user->role === 'admin' || $user->isParticipant($round->tournament);
+	}
 }
diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js
index aab4423..5a9c8b0 100644
--- a/resources/js/components/pages/Tournament.js
+++ b/resources/js/components/pages/Tournament.js
@@ -7,7 +7,7 @@ import ErrorMessage from '../common/ErrorMessage';
 import Loading from '../common/Loading';
 import NotFound from '../pages/NotFound';
 import Detail from '../tournament/Detail';
-import { patchResult, sortParticipants } from '../../helpers/Tournament';
+import { patchResult, patchRound, sortParticipants } from '../../helpers/Tournament';
 
 const Tournament = () => {
 	const params = useParams();
@@ -48,6 +48,11 @@ const Tournament = () => {
 						rounds: [...tournament.rounds, e.round],
 					}));
 				}
+			})
+			.listen('RoundChanged', e => {
+				if (e.round) {
+					setTournament(tournament => patchRound(tournament, e.round));
+				}
 			});
 		return () => {
 			window.Echo.leave(`Tournament.${id}`);
diff --git a/resources/js/components/rounds/Item.js b/resources/js/components/rounds/Item.js
index b227e93..d352cd1 100644
--- a/resources/js/components/rounds/Item.js
+++ b/resources/js/components/rounds/Item.js
@@ -3,9 +3,10 @@ import React from 'react';
 import { Button } from 'react-bootstrap';
 import { withTranslation } from 'react-i18next';
 
+import SeedButton from './SeedButton';
 import List from '../results/List';
 import ReportButton from '../results/ReportButton';
-import { isParticipant } from '../../helpers/permissions';
+import { maySetSeed, isParticipant } from '../../helpers/permissions';
 import { findParticipant } from '../../helpers/Tournament';
 import { withUser } from '../../helpers/UserContext';
 import i18n from '../../i18n';
@@ -18,13 +19,12 @@ const Item = ({
 <li className="round d-flex">
 	<div className="info">
 		<p className="date">{i18n.t('rounds.date', { date: new Date(round.created_at) })}</p>
-		{round.seed ?
-			<p className="seed">
-				<Button href={round.seed} target="_blank" variant="primary">
-					{i18n.t('rounds.seed')}
-				</Button>
-			</p>
-		: null}
+		<p className="seed">
+			<SeedButton
+				round={round}
+				tournament={tournament}
+			/>
+		</p>
 		{isParticipant(user, tournament) ?
 			<p className="report">
 				<ReportButton
diff --git a/resources/js/components/rounds/SeedButton.js b/resources/js/components/rounds/SeedButton.js
new file mode 100644
index 0000000..1d68b6a
--- /dev/null
+++ b/resources/js/components/rounds/SeedButton.js
@@ -0,0 +1,46 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import SeedDialog from './SeedDialog';
+import { maySetSeed } from '../../helpers/permissions';
+import { withUser } from '../../helpers/UserContext';
+import i18n from '../../i18n';
+
+const SeedButton = ({ round, tournament, user }) => {
+	const [showDialog, setShowDialog] = useState(false);
+
+	if (round.seed) {
+		return (
+			<Button href={round.seed} target="_blank" variant="primary">
+				{i18n.t('rounds.seed')}
+			</Button>
+		);
+	}
+	if (maySetSeed(user, tournament)) {
+		return <>
+			<SeedDialog
+				onHide={() => setShowDialog(false)}
+				round={round}
+				show={showDialog}
+			/>
+			<Button onClick={() => setShowDialog(true)} variant="outline-primary">
+				{i18n.t('rounds.setSeed')}
+			</Button>
+		</>;
+	}
+	return i18n.t('rounds.noSeed');
+};
+
+SeedButton.propTypes = {
+	round: PropTypes.shape({
+		seed: PropTypes.string,
+	}),
+	tournament: PropTypes.shape({
+	}),
+	user: PropTypes.shape({
+	}),
+};
+
+export default withTranslation()(withUser(SeedButton));
diff --git a/resources/js/components/rounds/SeedDialog.js b/resources/js/components/rounds/SeedDialog.js
new file mode 100644
index 0000000..c4db7d3
--- /dev/null
+++ b/resources/js/components/rounds/SeedDialog.js
@@ -0,0 +1,37 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import SeedForm from './SeedForm';
+import i18n from '../../i18n';
+
+const SeedDialog = ({
+	onHide,
+	participant,
+	round,
+	show,
+}) =>
+<Modal className="seed-dialog" onHide={onHide} show={show}>
+	<Modal.Header closeButton>
+		<Modal.Title>
+			{i18n.t('rounds.setSeed')}
+		</Modal.Title>
+	</Modal.Header>
+	<SeedForm
+		onCancel={onHide}
+		participant={participant}
+		round={round}
+	/>
+</Modal>;
+
+SeedDialog.propTypes = {
+	onHide: PropTypes.func,
+	round: PropTypes.shape({
+	}),
+	show: PropTypes.bool,
+	tournament: PropTypes.shape({
+	}),
+};
+
+export default withTranslation()(SeedDialog);
diff --git a/resources/js/components/rounds/SeedForm.js b/resources/js/components/rounds/SeedForm.js
new file mode 100644
index 0000000..e21da2e
--- /dev/null
+++ b/resources/js/components/rounds/SeedForm.js
@@ -0,0 +1,90 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+import yup from '../../schema/yup';
+
+const ReportForm = ({
+	errors,
+	handleBlur,
+	handleChange,
+	handleSubmit,
+	onCancel,
+	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')}
+			</Button>
+		: null}
+		<Button type="submit" variant="primary">
+			{i18n.t('button.save')}
+		</Button>
+	</Modal.Footer>
+</Form>;
+
+ReportForm.propTypes = {
+	errors: PropTypes.shape({
+		seed: PropTypes.string,
+	}),
+	handleBlur: PropTypes.func,
+	handleChange: PropTypes.func,
+	handleSubmit: PropTypes.func,
+	onCancel: PropTypes.func,
+	touched: PropTypes.shape({
+		seed: PropTypes.bool,
+	}),
+	values: PropTypes.shape({
+		seed: PropTypes.string,
+	}),
+};
+
+export default withFormik({
+	displayName: 'SeedForm',
+	enableReinitialize: true,
+	handleSubmit: async (values, actions) => {
+		const { round_id, seed } = values;
+		const { onCancel } = actions.props;
+		await axios.post(`/api/rounds/${round_id}/setSeed`, {
+			seed,
+		});
+		if (onCancel) {
+			onCancel();
+		}
+	},
+	mapPropsToValues: ({ round }) => ({
+		round_id: round.id,
+		seed: round.seed || '',
+	}),
+	validationSchema: yup.object().shape({
+		seed: yup.string().required().url(),
+	}),
+})(withTranslation()(ReportForm));
diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js
index 5716e68..6b4ca28 100644
--- a/resources/js/helpers/Tournament.js
+++ b/resources/js/helpers/Tournament.js
@@ -19,6 +19,14 @@ export const patchResult = (tournament, result) => {
 	};
 };
 
+export const patchRound = (tournament, round) => {
+	if (!tournament) return tournament;
+	return {
+		...tournament,
+		rounds: tournament.rounds.map(r => r.id === round.id ? round : r),
+	};
+};
+
 export const sortParticipants = tournament => {
 	if (!tournament || !tournament.participants || !tournament.participants.length) {
 		return tournament;
diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js
index 2565d44..b7d7cfd 100644
--- a/resources/js/helpers/permissions.js
+++ b/resources/js/helpers/permissions.js
@@ -20,6 +20,9 @@ export const hasFinished = (user, round) =>
 export const mayAddRounds = (user, tournament) =>
 	isAdmin(user) || isParticipant(user, tournament);
 
+export const maySetSeed = (user, tournament) =>
+	isAdmin(user) || isParticipant(user, tournament);
+
 export const mayViewProtocol = user =>
 	isAdmin(user);
 
diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js
index c978ca4..6bca83b 100644
--- a/resources/js/i18n/de.js
+++ b/resources/js/i18n/de.js
@@ -51,12 +51,15 @@ export default {
 			empty: 'Noch keine Runde gestartet',
 			heading: 'Runden',
 			new: 'Neue Runde',
+			noSeed: 'Noch kein Seed',
 			seed: 'Seed',
+			setSeed: 'Seed eintragen',
 		},
 		validation: {
 			error: {
 				required: 'Bitte ausfüllen',
 				time: 'Bitte Zeit im 1:23:45 Format eingeben (oder 56:23 wenn du schnell warst ^^).',
+				url: 'Bitte eine URL eingeben',
 			},
 		}
 	},
diff --git a/resources/js/schema/yup.js b/resources/js/schema/yup.js
index cc4e724..19f8b58 100644
--- a/resources/js/schema/yup.js
+++ b/resources/js/schema/yup.js
@@ -19,6 +19,7 @@ yup.setLocale({
 	},
 	string: {
 		time: 'validation.error.time',
+		url: 'validation.error.url',
 	},
 });
 
diff --git a/routes/api.php b/routes/api.php
index ca6b769..5b026e3 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -23,5 +23,6 @@ Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@for
 Route::post('results', 'App\Http\Controllers\ResultController@create');
 
 Route::post('rounds', 'App\Http\Controllers\RoundController@create');
+Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed');
 
 Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');