]> git.localhorst.tv Git - alttp.git/commitdiff
guessing game controls
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 29 Feb 2024 21:58:51 +0000 (22:58 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Thu, 29 Feb 2024 21:58:51 +0000 (22:58 +0100)
21 files changed:
app/Http/Controllers/ChannelController.php
app/Models/Channel.php
app/Models/GuessingGuess.php
app/Models/GuessingWinner.php
app/TwitchBot/ChatCommand.php
app/TwitchBot/GuessingCancelCommand.php
resources/js/app/Routes.js
resources/js/components/common/ChannelSelect.js
resources/js/components/common/Icon.js
resources/js/components/twitch-bot/Controls.js
resources/js/components/twitch-bot/GuessingGameControls.js [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingGuess.js [new file with mode: 0644]
resources/js/components/twitch-bot/GuessingWinner.js [new file with mode: 0644]
resources/js/helpers/Channel.js [new file with mode: 0644]
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/js/pages/GuessingGameControls.js [new file with mode: 0644]
resources/sass/app.scss
resources/sass/channels.scss [new file with mode: 0644]
routes/api.php
routes/channels.php

index cbd01104cb3e7cd497d42a44fd33206fdf214909..4cc49c1981fa1c5c6e06be05f43ea5eb3570aa98 100644 (file)
@@ -138,6 +138,82 @@ class ChannelController extends Controller {
                return $channel->toJson();
        }
 
+       public function controlGuessingGame(Request $request, Channel $channel, $name) {
+               $this->authorize('editRestream', $channel);
+
+               $validatedData = $request->validate([
+                       'action' => 'required|in:cancel,solve,start,stop',
+                       'solution' => '',
+               ]);
+
+               switch ($validatedData['action']) {
+                       case 'cancel':
+                               $channel->clearGuessing();
+                               $msg = $channel->getGuessingSetting('cancel_message');
+                               if (!empty($msg)) {
+                                       TwitchBotCommand::chat($channel->twitch_chat, $msg);
+                               }
+                               break;
+                       case 'solve':
+                               if ($channel->hasActiveGuessing() && $channel->isValidGuess($validatedData['solution'])) {
+                                       $winners = $channel->solveGuessing($validatedData['solution']);
+                                       $names = [];
+                                       foreach ($winners as $winner) {
+                                               if ($winner->score > 0) {
+                                                       $names[] = $winner->uname;
+                                               }
+                                       }
+                                       if (empty($names)) {
+                                               $msg = $channel->getGuessingSetting('no_winners_message');
+                                       } else {
+                                               $msg = $channel->getGuessingSetting('winners_message');
+                                               $msg = str_replace('{names}', $channel->listAnd($names), $msg);
+                                       }
+                                       if (!empty($msg)) {
+                                               TwitchBotCommand::chat($channel->twitch_chat, $msg);
+                                       }
+                                       $channel->clearGuessing();
+                               }
+                               break;
+                       case 'start':
+                               if (!$channel->hasActiveGuessing()) {
+                                       $channel->startGuessing($name);
+                                       $msg = $channel->getGuessingSetting('start_message');
+                                       if (!empty($msg)) {
+                                               TwitchBotCommand::chat($channel->twitch_chat, $msg);
+                                       }
+                               }
+                               break;
+                       case 'stop':
+                               if ($channel->hasActiveGuessing()) {
+                                       $channel->stopGuessing();
+                                       $msg = $channel->getGuessingSetting('stop_message');
+                                       if (!empty($msg)) {
+                                               TwitchBotCommand::chat($channel->twitch_chat, $msg);
+                                       }
+                               }
+                               break;
+               }
+
+               return $channel->toJson();
+       }
+
+       public function getGuessingGame(Channel $channel, $name) {
+               $this->authorize('editRestream', $channel);
+
+               $cutoff = $channel->guessing_start;
+               if (is_null($cutoff)) {
+                       $last = $channel->winners()->latest()->first();
+                       $cutoff = $last->pod;
+               }
+               $guesses = $channel->guesses()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get();
+               $winners = $channel->winners()->where('created_at', '>=', $cutoff)->orderBy('created_at')->get();
+               return [
+                       'guesses' => $guesses->toArray(),
+                       'winners' => $winners->toArray(),
+               ];
+       }
+
        public function saveGuessingGame(Request $request, Channel $channel, $name) {
                $this->authorize('editRestream', $channel);
 
@@ -150,7 +226,7 @@ class ChannelController extends Controller {
                        'points_exact_first' => 'numeric|min:1|max:5',
                        'points_exact_other' => 'numeric|min:0|max:5',
                        'points_close_first' => 'numeric|min:0|max:5',
-                       'points_close_min' => 'integer|min:0',
+                       'points_close_max' => 'integer|min:0',
                        'points_close_other' => 'numeric|min:0|max:5',
                        'start_message' => 'string',
                        'stop_message' => 'string',
index edc2ba8664be682bc3638fbb16e485b905ca55a1..d7d307e5294b4f6368f066d0c61ae18f8b39b1dc 100644 (file)
@@ -2,13 +2,24 @@
 
 namespace App\Models;
 
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Arr;
 
-class Channel extends Model
-{
+class Channel extends Model {
+
+       use BroadcastsEvents;
        use HasFactory;
 
+       public function broadcastOn($event) {
+               $channels = [
+                       new PrivateChannel('Channel.'.$this->id),
+               ];
+               return $channels;
+       }
+
        public function getCurrentEpisode() {
                return $this->episodes()
                        ->where('start', '<', now()->subMinutes(10))
@@ -135,6 +146,14 @@ class Channel extends Model
                return false;
        }
 
+       public function listAnd($entries) {
+               $lang = empty($this->languages) ? 'en' : $this->languages[0];
+               if ($lang == 'de') {
+                       return Arr::join($entries, ', ', ' und ');
+               }
+               return Arr::join($entries, ', ', ' and ');
+       }
+
        public function crews() {
                return $this->hasMany(ChannelCrew::class);
        }
index 12cff87895a61e6c110b01e16796462681ee9a87..b8e413b299e68b824e27837370b139f702979ba3 100644 (file)
@@ -2,15 +2,25 @@
 
 namespace App\Models;
 
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 
 class GuessingGuess extends Model {
 
+       use BroadcastsEvents;
        use HasFactory;
 
        public function channel() {
                return $this->belongsTo(Channel::class);
        }
 
+       public function broadcastOn($event) {
+               $channels = [
+                       new PrivateChannel('Channel.'.$this->channel_id),
+               ];
+               return $channels;
+       }
+
 }
index 0bbdf0061538f55a996604e9e79aac92641bb704..e6945bff37a6caa0980800b6f3330478bd13ecbc 100644 (file)
@@ -2,15 +2,25 @@
 
 namespace App\Models;
 
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Database\Eloquent\BroadcastsEvents;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
 
 class GuessingWinner extends Model {
 
+       use BroadcastsEvents;
        use HasFactory;
 
        public function channel() {
                return $this->belongsTo(Channel::class);
        }
 
+       public function broadcastOn($event) {
+               $channels = [
+                       new PrivateChannel('Channel.'.$this->channel_id),
+               ];
+               return $channels;
+       }
+
 }
index b00f0cf17d845b858544dde6aac76f9361475d21..f2e11b61dd4bda83c4eb89652eb9aed7aa4fb34e 100644 (file)
@@ -3,7 +3,6 @@
 namespace App\TwitchBot;
 
 use App\Models\Channel;
-use Illuminate\Support\Arr;
 
 abstract class ChatCommand {
 
@@ -62,11 +61,7 @@ abstract class ChatCommand {
        }
 
        protected function listAnd($entries) {
-               $lang = empty($this->channels->languages) ? 'en' : $this->channel->languages[0];
-               if ($lang == 'de') {
-                       return Arr::join($entries, ', ', ' und ');
-               }
-               return Arr::join($entries, ', ', ' and ');
+               return $this->channel->listAnd($entries);
        }
 
        protected function messageChannel($str) {
index 19f995345756a4ca00f2a333ff817815090c841e..bb6f361ed58d35fd5cda5ebd7a4575d370524e05 100644 (file)
@@ -5,7 +5,7 @@ namespace App\TwitchBot;
 class GuessingCancelCommand extends ChatCommand {
 
        public function execute($args) {
-               if ($this->chanel->hasActiveGuessing()) {
+               if ($this->channel->hasActiveGuessing()) {
                        $this->channel->clearGuessing();
                }
                $msg = $this->channel->getGuessingSetting('cancel_message');
index e5da45f293d28b74c7f4c2fbe6fee5b1a54a2707..3f73834311d5014845eb3de1e642c1d7047dbd21 100644 (file)
@@ -127,6 +127,15 @@ const router = createBrowserRouter(
                                        '../pages/DoorsTracker'
                                )}
                        />
+                       <Route path="guessing-game">
+                               <Route
+                                       path="controls"
+                                       lazy={() => import(
+                                               /* webpackChunkName: "guessing" */
+                                               '../pages/GuessingGameControls'
+                                       )}
+                               />
+                       </Route>
                </Route>
        )
 );
index 701f196591e1402e4cbd4936de7994b40a1e780f..2fbb2c1958f02a61c93bde7048440e7d11c643b4 100644 (file)
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
 import Icon from './Icon';
 import debounce from '../../helpers/debounce';
 
-const ChannelSelect = ({ joinable, manageable, onChange, value }) => {
+const ChannelSelect = ({ autoSelect, joinable, manageable, onChange, value }) => {
        const [resolved, setResolved] = useState(null);
        const [results, setResults] = useState([]);
        const [search, setSearch] = useState('');
@@ -47,11 +47,17 @@ const ChannelSelect = ({ joinable, manageable, onChange, value }) => {
                        });
                        ctrl = null;
                        setResults(response.data);
+                       if (autoSelect && !phrase && response.data.length === 1) {
+                               onChange({
+                                       channel: response.data[0],
+                                       target: { value: response.data[0].id },
+                               });
+                       }
                } catch (e) {
                        ctrl = null;
                        console.error(e);
                }
-       }, 300), [manageable]);
+       }, 300), [autoSelect, joinable, manageable]);
 
        useEffect(() => {
                fetch(search);
@@ -117,6 +123,7 @@ const ChannelSelect = ({ joinable, manageable, onChange, value }) => {
 };
 
 ChannelSelect.propTypes = {
+       autoSelect: PropTypes.bool,
        joinable: PropTypes.bool,
        manageable: PropTypes.bool,
        onChange: PropTypes.func,
index d27df434a465058d4c3b84f89d891d4b044f045a..8f03baed0ef06cb811fab5f8056ac5fef2286254 100644 (file)
@@ -81,6 +81,7 @@ Icon.MENU = makePreset('MenuIcon', 'bars');
 Icon.MICROPHONE = makePreset('MicrophoneIcon', 'microphone');
 Icon.MONITOR = makePreset('MonitorIcon', 'tv');
 Icon.MOUSE = makePreset('MouseIcon', 'arrow-pointer');
+Icon.OPEN = makePreset('OpenIcon', 'arrow-up-right-from-square');
 Icon.PAUSE = makePreset('PauseIcon', 'pause');
 Icon.PENDING = makePreset('PendingIcon', 'clock');
 Icon.PIN = makePreset('PinIcon', 'location-pin');
index 822c201b8fde5504b97c5553dbd484fe6bc536d6..84742d8aed9bf6fedeaf5b4860d66fcd661392cc 100644 (file)
@@ -9,6 +9,7 @@ import CommandDialog from './CommandDialog';
 import Commands from './Commands';
 import GuessingSettingsForm from './GuessingSettingsForm';
 import ChannelSelect from '../common/ChannelSelect';
+import Icon from '../common/Icon';
 import ToggleSwitch from '../common/ToggleSwitch';
 
 const Controls = () => {
@@ -121,6 +122,7 @@ const Controls = () => {
                                <Form.Label>{t('twitchBot.channel')}</Form.Label>
                                <Form.Control
                                        as={ChannelSelect}
+                                       autoSelect
                                        joinable
                                        manageable
                                        onChange={({ channel }) => { setChannel(channel); }}
@@ -228,7 +230,22 @@ const Controls = () => {
                                        </div>
                                </Col>
                                <Col className="mt-5" md={12}>
-                                       <h3>{t('twitchBot.guessingGame.settings')}</h3>
+                                       <div className="d-flex align-items-end justify-content-between">
+                                               <h3>{t('twitchBot.guessingGame.settings')}</h3>
+                                               <Button
+                                                       onClick={() => {
+                                                               window.open(
+                                                                       '/guessing-game/controls',
+                                                                       '',
+                                                                       'width=640,height=800,titlebar=0,menubar=0,toolbar=0',
+                                                               );
+                                                       }}
+                                                       title={t('twitchBot.guessingGame.popoutControls')}
+                                                       variant="outline-secondary"
+                                               >
+                                                       <Icon.OPEN title="" />
+                                               </Button>
+                                       </div>
                                        <GuessingSettingsForm
                                                name="gtbk"
                                                onSubmit={saveGuessingGame}
diff --git a/resources/js/components/twitch-bot/GuessingGameControls.js b/resources/js/components/twitch-bot/GuessingGameControls.js
new file mode 100644 (file)
index 0000000..c3846c5
--- /dev/null
@@ -0,0 +1,76 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import {
+       hasActiveGuessing,
+       isAcceptingGuesses,
+} from '../../helpers/Channel';
+
+const GuessingGameControls = ({
+       channel,
+       onCancel,
+       onSolve,
+       onStart,
+       onStop,
+}) => {
+       const { t } = useTranslation();
+
+       const solutions = [
+               1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
+               13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
+       ];
+
+       return <div>
+               <div className="button-bar mt-3">
+                       <Button
+                               onClick={onStart}
+                               variant={hasActiveGuessing(channel) ? 'success' : 'outline-success'}
+                       >
+                               {t('button.start')}
+                       </Button>
+                       <Button
+                               onClick={onStop}
+                               variant={
+                                       hasActiveGuessing(channel) && isAcceptingGuesses(channel)
+                                       ? 'danger' : 'outline-danger'
+                               }
+                       >
+                               {t('button.stop')}
+                       </Button>
+                       <Button
+                               className="ms-3"
+                               onClick={onCancel}
+                               variant={hasActiveGuessing(channel) ? 'secondary' : 'outline-secondary'}
+                       >
+                               {t('button.cancel')}
+                       </Button>
+               </div>
+               {hasActiveGuessing(channel) ?
+                       <div className="bkgg-buttons d-grid gap-3 my-3">
+                               {solutions.map(solution =>
+                                       <Button
+                                               key={solution}
+                                               onClick={() => onSolve(solution)}
+                                               size="lg"
+                                               variant="outline-secondary"
+                                       >
+                                               {solution}
+                                       </Button>
+                               )}
+                       </div>
+               : null}
+       </div>;
+};
+
+GuessingGameControls.propTypes = {
+       channel: PropTypes.shape({
+       }),
+       onCancel: PropTypes.func,
+       onSolve: PropTypes.func,
+       onStart: PropTypes.func,
+       onStop: PropTypes.func,
+};
+
+export default GuessingGameControls;
diff --git a/resources/js/components/twitch-bot/GuessingGuess.js b/resources/js/components/twitch-bot/GuessingGuess.js
new file mode 100644 (file)
index 0000000..61997d3
--- /dev/null
@@ -0,0 +1,34 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const GuessingGuess = ({ guess }) => {
+       const { t } = useTranslation();
+
+       return <div className="my-2 p-2 border rounded">
+               <Row>
+                       <Col xs={6}>
+                               <div>{guess.uname}</div>
+                               <div className="text-muted">
+                                       {t('twitchBot.guessingGame.guessTimestamp', {
+                                               timestamp: new Date(guess.created_at),
+                                       })}
+                               </div>
+                       </Col>
+                       <Col xs={6}>
+                               <div className="fs-3 text-end">{guess.guess}</div>
+                       </Col>
+               </Row>
+       </div>;
+};
+
+GuessingGuess.propTypes = {
+       guess: PropTypes.shape({
+               created_at: PropTypes.string,
+               guess: PropTypes.string,
+               uname: PropTypes.string,
+       }),
+};
+
+export default GuessingGuess;
diff --git a/resources/js/components/twitch-bot/GuessingWinner.js b/resources/js/components/twitch-bot/GuessingWinner.js
new file mode 100644 (file)
index 0000000..f7956ba
--- /dev/null
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Col, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+const GuessingWinner = ({ winner }) => {
+       const { t } = useTranslation();
+
+       const classNames = ['guessing-game-winner', 'my-2', 'p-2', 'border', 'rounded'];
+       if (!winner.score) {
+               classNames.push('no-points');
+       }
+
+       return <div className={classNames.join(' ')}>
+               <Row>
+                       <Col xs={6}>
+                               <div>{winner.uname}</div>
+                               <div>{t('twitchBot.guessingGame.winnerScore', { score: winner.score })}</div>
+                       </Col>
+                       <Col xs={6}>
+                               <div className="fs-3 text-end">{winner.guess}</div>
+                       </Col>
+               </Row>
+       </div>;
+};
+
+GuessingWinner.propTypes = {
+       winner: PropTypes.shape({
+               guess: PropTypes.string,
+               score: PropTypes.number,
+               uname: PropTypes.string,
+       }),
+};
+
+export default GuessingWinner;
diff --git a/resources/js/helpers/Channel.js b/resources/js/helpers/Channel.js
new file mode 100644 (file)
index 0000000..3155428
--- /dev/null
@@ -0,0 +1,11 @@
+export const hasActiveGuessing = channel =>
+       channel && channel.guessing_start;
+
+export const isAcceptingGuesses = channel =>
+       channel && channel.guessing_start && !channel.guessing_end;
+
+export const patchGuess = (guesses, guess) =>
+       [guess, ...(guesses || []).filter(g => g.uid !== guess.uid)];
+
+export const patchWinner = (winners, winner) =>
+       [winner, ...(winners || []).filter(w => w.uid !== winner.uid)];
index 9b7a0f33531afbc9b42646c141fec6489008cd84..262bc0d470e148a37756d4f7cfc36c43241831e0 100644 (file)
@@ -79,6 +79,7 @@ export default {
                        send: 'Senden',
                        settings: 'Einstellungen',
                        signUp: 'Anmelden',
+                       start: 'Start',
                        stop: 'Stop',
                        unconfirm: 'Zurückziehen',
                        unset: 'Zurücksetzen',
@@ -535,6 +536,8 @@ export default {
                                defaultStartMessage: 'Haut jetzt eure Zahlen raus!',
                                defaultStopMessage: 'Annahme geschlossen',
                                defaultWinnersMessage: 'Glückwunsch {names}!',
+                               guesses: 'Tipps',
+                               guessTimestamp: '{{ timestamp, LT }}',
                                invalidSolutionMessage: 'Nachricht bei ungültiger (oder fehlender) Lösung',
                                noWinnersMessage: 'Nachricht, falls keine Gewinner',
                                notActiveMessage: 'Nachricht, wenn kein Spiel läuft',
@@ -543,9 +546,13 @@ export default {
                                pointsCloseOther: 'Punkte für weitere nächste Treffer',
                                pointsExactFirst: 'Punkte für den ersten exakten Treffer',
                                pointsExactOther: 'Punkte für weitere exakte Treffer',
+                               popoutControls: 'Steuerung öffnen',
                                settings: 'Guessing Game Einstellungen',
                                startMessage: 'Nachricht bei Start',
                                stopMessage: 'Nachricht bei Einsendeschluss',
+                               winners: 'Gewinner',
+                               winnerScore: '{{ score }} Punkte',
+                               winnerScore_one: '{{ score }} Punkt',
                                winnersMessage: 'Ankündigung der Gewinner',
                                winnersMessageHint: '{names} wird durch die Namen der Gewinner ersetzt',
                        },
index 2bcbe91cea3af81444c507eaba3fa718c1505478..2a4f2cb8a82a4178137beb784d23d5b0451f1a60 100644 (file)
@@ -79,6 +79,7 @@ export default {
                        send: 'Send',
                        settings: 'Settings',
                        signUp: 'Sign up',
+                       start: 'Start',
                        stop: 'Stop',
                        unconfirm: 'Retract',
                        unset: 'Unset',
@@ -535,6 +536,8 @@ export default {
                                defaultStartMessage: 'Get your guesses in',
                                defaultStopMessage: 'Guessing closed',
                                defaultWinnersMessage: 'Congrats {names}!',
+                               guesses: 'Guesses',
+                               guessTimestamp: '{{ timestamp, LT }}',
                                invalidSolutionMessage: 'Message for invalid (or missing) solution',
                                noWinnersMessage: 'Announcement for no winners',
                                notActiveMessage: 'Message when no game is currently active',
@@ -543,9 +546,13 @@ export default {
                                pointsCloseOther: 'Points for further close matches',
                                pointsExactFirst: 'Points for first exact match',
                                pointsExactOther: 'Points for further exact matches',
+                               popoutControls: 'Popout controls',
                                settings: 'Guessing game settings',
                                startMessage: 'Starting announcement',
                                stopMessage: 'Closing announcement',
+                               winners: 'Winners',
+                               winnerScore: '{{ score }} points',
+                               winnerScore_one: '{{ score }} point',
                                winnersMessage: 'Winners announcement',
                                winnersMessageHint: '{names} will be replaced with a list of winners\' names',
                        },
diff --git a/resources/js/pages/GuessingGameControls.js b/resources/js/pages/GuessingGameControls.js
new file mode 100644 (file)
index 0000000..1c84689
--- /dev/null
@@ -0,0 +1,157 @@
+import axios from 'axios';
+import React from 'react';
+import { Alert, Col, Container, Form, Navbar, Row } from 'react-bootstrap';
+import { Helmet } from 'react-helmet';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import User from '../app/User';
+import ChannelSelect from '../components/common/ChannelSelect';
+import GuessingGameControls from '../components/twitch-bot/GuessingGameControls';
+import GuessingGuess from '../components/twitch-bot/GuessingGuess';
+import GuessingWinner from '../components/twitch-bot/GuessingWinner';
+import { patchGuess, patchWinner } from '../helpers/Channel';
+
+export const Component = () => {
+       const [channel, setChannel] = React.useState(null);
+       const [guesses, setGuesses] = React.useState([]);
+       const [winners, setWinners] = React.useState([]);
+
+       const { t } = useTranslation();
+
+       React.useEffect(() => {
+               if (!channel) {
+                       setGuesses([]);
+                       setWinners([]);
+                       return;
+               }
+               if (channel.guessing_type) {
+                       axios.get(`/api/channels/${channel.id}/guessing-game/${channel.guessing_type}`)
+                               .then(res => {
+                                       res.data.guesses.forEach(g => {
+                                               setGuesses(gs => patchGuess(gs, g));
+                                       });
+                                       res.data.winners.forEach(w => {
+                                               setWinners(ws => patchGuess(ws, w));
+                                       });
+                               });
+               }
+               window.Echo.private(`Channel.${channel.id}`)
+                       .listen('.GuessingGuessCreated', (e) => {
+                               setGuesses(gs => patchGuess(gs, e.model));
+                       })
+                       .listen('.GuessingWinnerCreated', (e) => {
+                               setWinners(ws => patchWinner(ws, e.model));
+                       })
+                       .listen('.ChannelUpdated', (e) => {
+                               setChannel(c => ({ ...c, ...e.model }));
+                       });
+               return () => {
+                       window.Echo.leave(`Channel.${channel.id}`);
+               };
+       }, [channel && channel.id]);
+
+       React.useEffect(() => {
+               const cutoff = channel && channel.guessing_start;
+               if (cutoff) {
+                       setGuesses(gs => gs.filter(g => g.created_at >= cutoff));
+                       setWinners(ws => ws.filter(w => w.created_at >= cutoff));
+               }
+       }, [channel && channel.guessing_start]);
+
+       const onCancel = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'cancel' },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       const onSolve = React.useCallback(async (solution) => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'solve', solution },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       const onStart = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'start' },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       const onStop = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(
+                               `/api/channels/${channel.id}/guessing-game/gtbk`,
+                               { action: 'stop' },
+                       );
+                       setChannel(rsp.data);
+               } catch (e) {
+                       toastr.error(t('twitchBot.controlError'));
+               }
+       }, [channel]);
+
+       return <>
+               <Helmet>
+                       <title>Guessing Game Controls</title>
+               </Helmet>
+               <Navbar id="header" bg="dark" variant="dark">
+                       <Container fluid>
+                               <Form.Control
+                                       as={ChannelSelect}
+                                       autoSelect
+                                       joinable
+                                       manageable
+                                       onChange={({ channel }) => { setChannel(channel); }}
+                                       value={channel ? channel.id : ''}
+                               />
+                               <User />
+                       </Container>
+               </Navbar>
+               <Container fluid>
+               {channel ? <Row>
+                       <Col md={12} lg={6}>
+                               <GuessingGameControls
+                                       channel={channel}
+                                       onCancel={onCancel}
+                                       onSolve={onSolve}
+                                       onStart={onStart}
+                                       onStop={onStop}
+                               />
+                       </Col>
+                       <Col md={6} lg={3}>
+                               <h3 className="mt-3">{t('twitchBot.guessingGame.winners')}</h3>
+                               {winners.map(winner =>
+                                       <GuessingWinner key={winner.id} winner={winner} />
+                               )}
+                       </Col>
+                       <Col md={6} lg={3}>
+                               <h3 className="mt-3">{t('twitchBot.guessingGame.guesses')}</h3>
+                               {guesses.map(guess =>
+                                       <GuessingGuess guess={guess} key={guess.id} />
+                               )}
+                       </Col>
+               </Row> :
+                       <Alert variant="info">
+                               {t('twitchBot.selectChannel')}
+                       </Alert>
+               }
+               </Container>
+       </>;
+};
index e05a274628eca684c9a4bef41e927f2066664c25..841390814b7f77a6bca1e305c9b545a03a97d35e 100644 (file)
@@ -6,6 +6,7 @@
 
 // Custom
 @import 'common';
+@import 'channels';
 @import 'discord';
 @import 'doors';
 @import 'episodes';
diff --git a/resources/sass/channels.scss b/resources/sass/channels.scss
new file mode 100644 (file)
index 0000000..cffa578
--- /dev/null
@@ -0,0 +1,7 @@
+.bkgg-buttons {
+       grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
+}
+.guessing-game-winner.no-points {
+       opacity: .6;
+       background: repeating-linear-gradient(150deg, #444, #444 1ex, transparent 1ex, transparent 1em);
+}
index 276ff634641dc6234d16d2e869b26be2bf35c211..2135a2fb1d7218070954d6d090adfafc3b48ebf1 100644 (file)
@@ -32,6 +32,8 @@ Route::post('channels/{channel}/join', 'App\Http\Controllers\ChannelController@j
 Route::post('channels/{channel}/part', 'App\Http\Controllers\ChannelController@part');
 Route::delete('channels/{channel}/commands/{command}', 'App\Http\Controllers\ChannelController@deleteCommand');
 Route::put('channels/{channel}/commands/{command}', 'App\Http\Controllers\ChannelController@saveCommand');
+Route::get('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@getGuessingGame');
+Route::post('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@controlGuessingGame');
 Route::put('channels/{channel}/guessing-game/{name}', 'App\Http\Controllers\ChannelController@saveGuessingGame');
 
 Route::get('content', 'App\Http\Controllers\TechniqueController@search');
index e93d25e9c284e4afa7c4c91876f6688e97133590..b0a25526a12701f2f783d06a128c48433c7be02c 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 
+use App\Models\Channel;
 use App\Models\Tournament;
 use Illuminate\Support\Facades\Broadcast;
 
@@ -22,6 +23,11 @@ Broadcast::channel('App.Control', function ($user) {
        return true;
 });
 
+Broadcast::channel('Channel.{id}', function ($user, $id) {
+       $channel = Channel::findOrFail($id);
+       return $user->can('editRestream', $channel);
+});
+
 Broadcast::channel('Protocol.{id}', function ($user, $id) {
        $tournament = Tournament::findOrFail($id);
        return $user->can('viewProtocol', $tournament);