]> git.localhorst.tv Git - alttp.git/commitdiff
basic twitch join/part commands
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 6 Oct 2023 14:22:57 +0000 (16:22 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 6 Oct 2023 14:22:57 +0000 (16:22 +0200)
18 files changed:
app/Http/Controllers/ChannelController.php [new file with mode: 0644]
app/Models/Channel.php
app/Models/TwitchBotCommand.php [new file with mode: 0644]
app/Policies/ChannelPolicy.php
app/TwitchBot/IRCMessage.php
app/TwitchBot/TwitchBot.php
app/TwitchBotCommands/BaseCommand.php [new file with mode: 0644]
app/TwitchBotCommands/JoinCommand.php [new file with mode: 0644]
app/TwitchBotCommands/PartCommand.php [new file with mode: 0644]
database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php [new file with mode: 0644]
database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php [new file with mode: 0644]
resources/js/components/common/ChannelSelect.js [new file with mode: 0644]
resources/js/components/twitch-bot/Controls.js
resources/js/helpers/permissions.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/episodes.scss
routes/api.php

diff --git a/app/Http/Controllers/ChannelController.php b/app/Http/Controllers/ChannelController.php
new file mode 100644 (file)
index 0000000..904cb8f
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Channel;
+use App\Models\TwitchBotCommand;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\Request;
+
+class ChannelController extends Controller {
+
+       public function search(Request $request) {
+               $validatedData = $request->validate([
+                       'joinable' => 'boolean|nullable',
+                       'manageable' => 'boolean|nullable',
+                       'phrase' => 'string|nullable',
+               ]);
+
+               $channels = Channel::query();
+               if (isset($validatedData['joinable']) && $validatedData['joinable']) {
+                       $channels = $channels->where('twitch_chat', '!=', '');
+               }
+               if (isset($validatedData['manageable']) && $validatedData['manageable']) {
+                       $user = $request->user();
+                       if (!$user) {
+                               return [];
+                       }
+                       $channels = $channels->whereHas('crews', function (Builder $query) use ($user) {
+                               $query->where('user_id', '=', $user->id);
+                       });
+               }
+               if (!empty($validatedData['phrase'])) {
+                       $channels = $channels->where('title', 'LIKE', '%'.$validatedData['phrase'].'%')
+                               ->orWhere('short_name', 'LIKE', '%'.$validatedData['phrase'].'%');
+               }
+               $channels = $channels->limit(5);
+               return $channels->get()->toJson();
+       }
+
+       public function single(Request $request, Channel $channel) {
+               $this->authorize('view', $channel);
+               return $channel->toJson();
+       }
+
+       public function join(Request $request, Channel $channel) {
+               if (!$channel->twitch_chat) {
+                       throw new \Exception('channel has no twitch chat set');
+               }
+               $this->authorize('editRestream', $channel);
+               $channel->join = true;
+               $channel->save();
+               TwitchBotCommand::join($channel->twitch_chat);
+               return $channel->toJson();
+       }
+
+       public function part(Request $request, Channel $channel) {
+               if (!$channel->twitch_chat) {
+                       throw new \Exception('channel has no twitch chat set');
+               }
+               $this->authorize('editRestream', $channel);
+               $channel->join = false;
+               $channel->save();
+               TwitchBotCommand::part($channel->twitch_chat);
+               return $channel->toJson();
+       }
+
+}
index 0536aef36e9638ace6387d2f3fbba3f50c2bcace..025bb34151cdd9ab56fa32b59c6f89412fd05616 100644 (file)
@@ -16,6 +16,10 @@ class Channel extends Model
                        ->first();
        }
 
+       public function crews() {
+               return $this->hasMany(ChannelCrew::class);
+       }
+
        public function episodes() {
                return $this->belongsToMany(Episode::class)
                        ->using(Restream::class)
@@ -29,6 +33,7 @@ class Channel extends Model
        protected $casts = [
                'chat_commands' => 'array',
                'languages' => 'array',
+               'join' => 'boolean',
        ];
 
        protected $hidden = [
diff --git a/app/Models/TwitchBotCommand.php b/app/Models/TwitchBotCommand.php
new file mode 100644 (file)
index 0000000..f2b35e3
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Models;
+
+use App\TwitchBot\TwitchBot;
+use App\TwitchBotCommands\BaseCommand;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class TwitchBotCommand extends Model
+{
+       use HasFactory;
+
+       public static function join($channel) {
+               $cmd = new TwitchBotCommand();
+               $cmd->command = 'join';
+               $cmd->parameters = [
+                       'channel' => $channel,
+               ];
+               $cmd->status = 'pending';
+               $cmd->save();
+       }
+
+       public static function part($channel) {
+               $cmd = new TwitchBotCommand();
+               $cmd->command = 'part';
+               $cmd->parameters = [
+                       'channel' => $channel,
+               ];
+               $cmd->status = 'pending';
+               $cmd->save();
+       }
+
+       public function tournament() {
+               return $this->belongsTo(Tournament::class);
+       }
+
+       public function user() {
+               return $this->belongsTo(User::class);
+       }
+
+       public function execute(TwitchBot $bot) {
+               $this->setExecuting();
+
+               try {
+                       BaseCommand::resolve($bot, $this)
+                               ->execute()
+                               ->otherwise(function (\Throwable $e) {
+                                       $this->setException($e);
+                               })
+                               ->done(function($v = null) {
+                                       $this->setDone();
+                               });
+               } catch (\Exception $e) {
+                       $this->setException($e);
+               }
+       }
+
+
+       private function setDone() {
+               $this->status = 'done';
+               $this->save();
+       }
+
+       private function setExecuting() {
+               $this->status = 'executing';
+               $this->executed_at = now();
+               $this->save();
+       }
+
+       private function setException(\Throwable $e) {
+               $this->status = 'exception';
+               $this->result = [
+                       'type' => get_class($e),
+                       'file' => $e->getFile(),
+                       'line' => $e->getLine(),
+                       'message' => $e->getMessage(),
+               ];
+               $this->save();
+       }
+
+
+       protected $casts = [
+               'parameters' => 'array',
+               'result' => 'array',
+               'executed_at' => 'datetime',
+       ];
+
+}
index 3e42c9f2790cc613809f9d158412951106f0e73a..21dc14b1a2174ef3301cfaa856e2b7a7bfd94235 100644 (file)
@@ -30,7 +30,7 @@ class ChannelPolicy
         */
        public function view(User $user, Channel $channel)
        {
-               return $channel->event->visible;
+               return true;
        }
 
        /**
index 00217dc044660b1a5b70c471a8f20497e611fad1..c730ef7d0bfa962126417f7b513e7adc46984b9b 100644 (file)
@@ -132,6 +132,13 @@ class IRCMessage {
                return $msg;
        }
 
+       public static function part($channels) {
+               $msg = new IRCMessage();
+               $msg->command = 'PART';
+               $msg->params[] = implode(',', $channels);
+               return $msg;
+       }
+
        public static function privmsg($target, $message) {
                $msg = new IRCMessage();
                $msg->command = 'PRIVMSG';
index 3cba5d958bac4975fd2e3fdf93fd901208ffcc18..ddc51fe046b2e68d448a18b04cc272ab8702a8c3 100644 (file)
@@ -3,6 +3,7 @@
 namespace App\TwitchBot;
 
 use App\Models\Channel;
+use App\Models\TwitchBotCommand;
 use App\Models\TwitchToken;
 use Monolog\Handler\StreamHandler;
 use Monolog\Logger;
@@ -24,6 +25,7 @@ class TwitchBot {
 
                $this->connector = new Connector();
                $this->connect();
+               $this->listenCommands();
        }
 
        public function getLogger() {
@@ -75,12 +77,13 @@ class TwitchBot {
        public function handleWsMessage(Message $message, WebSocket $ws) {
                $irc_messages = explode("\r\n", rtrim($message->getPayload(), "\r\n"));
                foreach ($irc_messages as $irc_message) {
-                       $this->logger->debug('received IRC message '.$irc_message);
+                       $this->logger->info('received IRC message '.$irc_message);
                        $this->handleIRCMessage(IRCMessage::fromString($irc_message));
                }
        }
 
        public function handleWsClose(int $op, string $reason) {
+               $this->ready = false;
                $this->logger->info('websocket connection closed: '.$reason.' ['.$op.']');
                if (!$this->shutting_down) {
                        $this->logger->info('reconnecting in 10 seconds');
@@ -111,6 +114,7 @@ class TwitchBot {
                if ($msg->command == '001') {
                        // successful login
                        $this->joinChannels();
+                       $this->ready = true;
                        return;
                }
        }
@@ -145,7 +149,7 @@ class TwitchBot {
 
        public function joinChannels() {
                $this->logger->info('joining channels');
-               $channels = Channel::where('twitch_chat', '!=', '')->get();
+               $channels = Channel::where('twitch_chat', '!=', '')->where('join', '=', true)->get();
                $names = [];
                foreach ($channels as $channel) {
                        $names[] = $channel->twitch_chat;
@@ -156,9 +160,23 @@ class TwitchBot {
                }
        }
 
+       private function listenCommands() {
+               $this->getLoop()->addPeriodicTimer(1, function () {
+                       if (!$this->ready) return;
+                       $command = TwitchBotCommand::where('status', '=', 'pending')->oldest()->first();
+                       if ($command) {
+                               try {
+                                       $command->execute($this);
+                               } catch (\Exception $e) {
+                               }
+                       }
+               });
+
+       }
+
        public function sendIRCMessage(IRCMessage $msg) {
                $irc_message = $msg->encode();
-               $this->logger->debug('sending IRC message '.$irc_message);
+               $this->logger->info('sending IRC message '.$irc_message);
                $this->ws->send($irc_message);
        }
 
@@ -169,6 +187,7 @@ class TwitchBot {
 
        private $connector;
        private $ws;
+       private $ready = false;
        private $shutting_down = false;
 
 }
diff --git a/app/TwitchBotCommands/BaseCommand.php b/app/TwitchBotCommands/BaseCommand.php
new file mode 100644 (file)
index 0000000..881d4ed
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+namespace App\TwitchBotCommands;
+
+use App\Models\TwitchBotCommand;
+use App\Models\User;
+use App\TwitchBot\TwitchBot;
+
+abstract class BaseCommand {
+
+       public static function resolve(TwitchBot $bot, TwitchBotCommand $cmd) {
+               switch ($cmd->command) {
+                       case 'join':
+                               return new JoinCommand($bot, $cmd);
+                       case 'part':
+                               return new PartCommand($bot, $cmd);
+                       default:
+                               throw new Exception('unrecognized command');
+               }
+       }
+
+       public abstract function execute();
+
+       protected function __construct(TwitchBot $bot, TwitchBotCommand $cmd) {
+               $this->bot = $bot;
+               $this->command = $cmd;
+               if ($cmd->tournament && $cmd->tournament->locale) {
+                       App::setLocale($cmd->tournament->locale);
+               }
+       }
+
+       protected function getParameter($name) {
+               return $this->command->parameters[$name];
+       }
+
+       protected function getUser() {
+               if (!$this->hasParameter('user')) {
+                       throw new \Exception('no user in parameters');
+               }
+               return User::findOrFail($this->getParameter('user'));
+       }
+
+       protected function hasParameter($name) {
+               return array_key_exists($name, $this->command->parameters);
+       }
+
+       protected $bot;
+       protected $command;
+
+}
diff --git a/app/TwitchBotCommands/JoinCommand.php b/app/TwitchBotCommands/JoinCommand.php
new file mode 100644 (file)
index 0000000..38b6e1e
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace App\TwitchBotCommands;
+
+use App\Models\TwitchBotCommand;
+use App\TwitchBot\IRCMessage;
+use App\TwitchBot\TwitchBot;
+use React\Promise\Promise;
+
+class JoinCommand extends BaseCommand {
+
+       public function __construct(TwitchBot $bot, TwitchBotCommand $cmd) {
+               parent::__construct($bot, $cmd);
+       }
+
+       public function execute() {
+               return new Promise(function($resolve) {
+                       $this->bot->sendIRCMessage(IRCMessage::join([$this->getParameter('channel')]));
+                       $resolve();
+               });
+       }
+
+}
diff --git a/app/TwitchBotCommands/PartCommand.php b/app/TwitchBotCommands/PartCommand.php
new file mode 100644 (file)
index 0000000..0a76a95
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+
+namespace App\TwitchBotCommands;
+
+use App\Models\TwitchBotCommand;
+use App\TwitchBot\IRCMessage;
+use App\TwitchBot\TwitchBot;
+use React\Promise\Promise;
+
+class PartCommand extends BaseCommand {
+
+       public function __construct(TwitchBot $bot, TwitchBotCommand $cmd) {
+               parent::__construct($bot, $cmd);
+       }
+
+       public function execute() {
+               return new Promise(function($resolve) {
+                       $this->bot->sendIRCMessage(IRCMessage::part([$this->getParameter('channel')]));
+                       $resolve();
+               });
+       }
+
+}
diff --git a/database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php b/database/migrations/2023_10_04_145919_add_join_field_to_channels_table.php
new file mode 100644 (file)
index 0000000..d9d5651
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::table('channels', function (Blueprint $table) {
+                       $table->boolean('join')->default(false);
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::table('channels', function (Blueprint $table) {
+                       $table->dropColumn('join');
+               });
+       }
+};
diff --git a/database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php b/database/migrations/2023_10_06_120722_create_twitch_bot_commands_table.php
new file mode 100644 (file)
index 0000000..900468e
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+       /**
+        * Run the migrations.
+        *
+        * @return void
+        */
+       public function up()
+       {
+               Schema::create('twitch_bot_commands', function (Blueprint $table) {
+                       $table->id();
+                       $table->foreignId('tournament_id')->nullable()->constrained();
+                       $table->foreignId('user_id')->nullable()->constrained();
+                       $table->string('command');
+                       $table->text('parameters')->nullable()->default(null);
+                       $table->string('status')->default('hold');
+                       $table->text('result')->nullable()->default(null);
+                       $table->timestamp('executed_at')->nullable()->default(null);
+                       $table->timestamps();
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        *
+        * @return void
+        */
+       public function down()
+       {
+               Schema::dropIfExists('twitch_bot_commands');
+       }
+};
diff --git a/resources/js/components/common/ChannelSelect.js b/resources/js/components/common/ChannelSelect.js
new file mode 100644 (file)
index 0000000..701f196
--- /dev/null
@@ -0,0 +1,129 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Alert, Button, Form, ListGroup } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import Icon from './Icon';
+import debounce from '../../helpers/debounce';
+
+const ChannelSelect = ({ joinable, manageable, onChange, value }) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = useRef(null);
+       const { t } = useTranslation();
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('click', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('click', handleEventOutside, true);
+                       document.removeEventListener('focus', handleEventOutside, true);
+               };
+       }, []);
+
+       let ctrl = null;
+       const fetch = useCallback(debounce(async phrase => {
+               if (ctrl) {
+                       ctrl.abort();
+               }
+               ctrl = new AbortController();
+               try {
+                       const response = await axios.get(`/api/channels`, {
+                               params: {
+                                       joinable: joinable ? 1 : 0,
+                                       manageable: manageable ? 1 : 0,
+                                       phrase,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+       }, 300), [manageable]);
+
+       useEffect(() => {
+               fetch(search);
+       }, [search]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/channels/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div className="d-flex align-items-center justify-content-between">
+                       <span>{resolved ? resolved.title : value}</span>
+                       <Button
+                               className="ms-2"
+                               onClick={() => onChange({ channel: null, target: { value: '' }})}
+                               title={t('button.unset')}
+                               variant="outline-danger"
+                       >
+                               <Icon.REMOVE title="" />
+                       </Button>
+               </div>;
+       }
+       return <div className={`channel-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+               <Form.Control
+                       className="search-input"
+                       name={Math.random().toString(20).substr(2, 10)}
+                       onChange={e => setSearch(e.target.value)}
+                       onFocus={() => setShowResults(true)}
+                       type="search"
+                       value={search}
+               />
+               <div className="search-results-holder">
+                       {results.length ?
+                               <ListGroup className="search-results">
+                                       {results.map(result =>
+                                               <ListGroup.Item
+                                                       action
+                                                       key={result.id}
+                                                       onClick={() => onChange({
+                                                               channel: result,
+                                                               target: { value: result.id },
+                                                       })}
+                                               >
+                                                       {result.title}
+                                               </ListGroup.Item>
+                                       )}
+                               </ListGroup>
+                       :
+                               <Alert className="search-results" variant="info">
+                                       {t('search.noResults')}
+                               </Alert>
+                       }
+               </div>
+       </div>;
+};
+
+ChannelSelect.propTypes = {
+       joinable: PropTypes.bool,
+       manageable: PropTypes.bool,
+       onChange: PropTypes.func,
+       value: PropTypes.oneOfType([
+               PropTypes.number,
+               PropTypes.string,
+       ]),
+};
+
+export default ChannelSelect;
index 158fb7c7ab5d8a58df9972e19f4162e1c8b5762b..0aea916cf04d698a879bc6bed886a88ebb426e08 100644 (file)
@@ -1,7 +1,76 @@
+import axios from 'axios';
 import React from 'react';
+import { Alert, Col, Form, Row } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
+
+import ChannelSelect from '../common/ChannelSelect';
+import ToggleSwitch from '../common/ToggleSwitch';
 
 const Controls = () => {
-       return <div />;
+       const [channel, setChannel] = React.useState(null);
+
+       const { t } = useTranslation();
+
+       const join = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(`/api/channels/${channel.id}/join`);
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.joinSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.joinError'));
+               }
+       }, [channel, t]);
+
+       const part = React.useCallback(async () => {
+               try {
+                       const rsp = await axios.post(`/api/channels/${channel.id}/part`);
+                       setChannel(rsp.data);
+                       toastr.success(t('twitchBot.partSuccess'));
+               } catch (e) {
+                       toastr.error(t('twitchBot.partError'));
+               }
+       }, [channel, t]);
+
+       return <>
+               <Row className="mb-4">
+                       <Form.Group as={Col} md={6}>
+                               <Form.Label>{t('twitchBot.channel')}</Form.Label>
+                               <Form.Control
+                                       as={ChannelSelect}
+                                       joinable
+                                       manageable
+                                       onChange={({ channel }) => { setChannel(channel); }}
+                                       value={channel ? channel.id : ''}
+                               />
+                       </Form.Group>
+                       {channel ?
+                               <Form.Group as={Col} md={6}>
+                                       <Form.Label>{t('twitchBot.join')}</Form.Label>
+                                       <div>
+                                               <Form.Control
+                                                       as={ToggleSwitch}
+                                                       onChange={({ target: { value } }) => {
+                                                               if (value) {
+                                                                       join();
+                                                               } else {
+                                                                       part();
+                                                               }
+                                                       }}
+                                                       value={channel.join}
+                                               />
+                                       </div>
+                               </Form.Group>
+                       : null}
+               </Row>
+               {channel ?
+                       <div />
+               :
+                       <Alert variant="info">
+                               {t('twitchBot.selectChannel')}
+                       </Alert>
+               }
+       </>;
 };
 
 export default Controls;
index 706e676ab692d5b44ec606e66ad8fc01c13a372f..23aba6955b2eb2783894497f85bf881ce43bca66 100644 (file)
@@ -17,6 +17,9 @@ export const isChannelAdmin = (user, channel) =>
        user && channel && user.channel_crews &&
                user.channel_crews.find(c => c.role === 'admin' && c.channel_id === channel.id);
 
+export const isAnyChannelAdmin = user =>
+       user && user.channel_crews && user.channel_crews.find(c => c.role === 'admin');
+
 // Content
 
 export const mayEditContent = user =>
@@ -37,8 +40,7 @@ export const isTracker = (user, episode) => {
 export const episodeHasChannel = (episode, channel) =>
        episode && channel && episode.channels && episode.channels.find(c => c.id === channel.id);
 
-export const mayRestreamEpisodes = user =>
-       user && user.channel_crews && user.channel_crews.find(c => c.role === 'admin');
+export const mayRestreamEpisodes = user => isAnyChannelAdmin(user);
 
 export const mayEditRestream = (user, episode, channel) =>
        episodeHasChannel(episode, channel) && isChannelAdmin(user, channel);
@@ -157,7 +159,7 @@ export const maySeeResults = (user, tournament, round) =>
 
 // Twitch
 
-export const mayManageTwitchBot = user => isAdmin(user) || hasGlobalRole(user, 'twitch');
+export const mayManageTwitchBot = user => isAnyChannelAdmin(user);
 
 // Users
 
index 9b6891b02941662d00d2326df62ea62c5ae7498a..8d4e5e4c8c07b6084f9afa6255bbaf9d4e766943 100644 (file)
@@ -475,9 +475,16 @@ export default {
                        unlockSuccess: 'Turnier entsperrt',
                },
                twitchBot: {
+                       channel: 'Channel',
                        controls: 'Controls',
                        heading: 'Twitch Bot',
+                       join: 'Join',
+                       joinError: 'Fehler beim Betreten',
+                       joinSuccess: 'Betreten',
                        noManagePermission: 'Du verfügst nicht über die notwendigen Berechtigungen, um den Twitch Bot zu administrieren.',
+                       partError: 'Fehler beim Verlassen',
+                       partSuccess: 'Verlassen',
+                       selectChannel: 'Bitte wählen einen Channel, den du verändern möchtest.',
                },
                users: {
                        discordTag: 'Discord Tag',
index 58d406ca14441aaf55f64261fcb4dd643cb94a13..480a57cc204ed8a0a6876d7f872d9b97e1ad02dd 100644 (file)
@@ -475,9 +475,16 @@ export default {
                        unlockSuccess: 'Tournament unlocked',
                },
                twitchBot: {
+                       channel: 'Channel',
                        controls: 'Controls',
                        heading: 'Twitch Bot',
+                       join: 'Join',
+                       joinError: 'Error joining channel',
+                       joinSuccess: 'Joined',
                        noManagePermission: 'You lack the required privileges to manage the twitch bot.',
+                       partError: 'Error parting channel',
+                       partSuccess: 'Parted',
+                       selectChannel: 'Please select a channel to manage.',
                },
                users: {
                        discordTag: 'Discord tag',
index eed191331be3d897508843e7313e232d517100b9..6caf0dd6eafc8cdbaf9e23f71b5ce91be3eeb3f5 100644 (file)
                }
        }
 }
+
+.channel-select {
+       .search-results-holder {
+               position: relative;
+       }
+       .search-results {
+               position: absolute;
+               left: 0;
+               top: 100%;
+               z-index: 1;
+               width: 100%;
+               border-top-left-radius: 0;
+               border-top-right-radius: 0;
+               box-shadow: 1ex 1ex 1ex rgba(0, 0, 0, 0.5);
+       }
+       &.collapsed .search-results {
+               display: none;
+       }
+       &.expanded .search-input {
+               border-bottom-left-radius: 0;
+               border-bottom-right-radius: 0;
+       }
+}
index 1d1c58054989fde9899ca542fc0371b0124198be..6380b2d2a6d9e4b2d40b0299044ec5b177586237 100644 (file)
@@ -24,6 +24,11 @@ Route::post('alttp-seed/{hash}/retry', 'App\Http\Controllers\AlttpSeedController
 Route::post('application/{application}/accept', 'App\Http\Controllers\ApplicationController@accept');
 Route::post('application/{application}/reject', 'App\Http\Controllers\ApplicationController@reject');
 
+Route::get('channels', 'App\Http\Controllers\ChannelController@search');
+Route::get('channels/{channel}', 'App\Http\Controllers\ChannelController@single');
+Route::post('channels/{channel}/join', 'App\Http\Controllers\ChannelController@join');
+Route::post('channels/{channel}/part', 'App\Http\Controllers\ChannelController@part');
+
 Route::get('content', 'App\Http\Controllers\TechniqueController@search');
 Route::get('content/{tech:name}', 'App\Http\Controllers\TechniqueController@single');
 Route::put('content/{content}', 'App\Http\Controllers\TechniqueController@update');