]> git.localhorst.tv Git - alttp.git/commitdiff
discord event subscription management
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 7 Jul 2025 11:36:05 +0000 (13:36 +0200)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Mon, 7 Jul 2025 11:36:05 +0000 (13:36 +0200)
20 files changed:
app/Http/Controllers/DiscordGuildController.php
app/Http/Controllers/EventController.php
app/Http/Controllers/UserController.php
app/Models/DiscordGuildEventSubscription.php
app/Models/DiscordGuildUserSubscription.php
database/migrations/2025_07_07_112614_discord_subscriptions_unique.php [new file with mode: 0644]
resources/js/components/common/DiscordChannelSelect.jsx
resources/js/components/common/DiscordSelect.jsx
resources/js/components/common/EventSelect.jsx [new file with mode: 0644]
resources/js/components/common/UserSelect.jsx
resources/js/components/discord-bot/EventSubscriptions.jsx [new file with mode: 0644]
resources/js/components/discord-bot/GuildControls.jsx
resources/js/components/discord-bot/UserSubscriptions.jsx [new file with mode: 0644]
resources/js/components/events/Box.jsx [new file with mode: 0644]
resources/js/helpers/Event.js
resources/js/i18n/de.js
resources/js/i18n/en.js
resources/sass/discord.scss
resources/sass/form.scss
routes/api.php

index 3b4a02af5f4a2d66bb4250bb940cfa8e947ce732..6c1e2213a15fecc70a46fb188c4c783f30cfb86c 100644 (file)
@@ -41,4 +41,37 @@ class DiscordGuildController extends Controller
                return $guild->toJson();
        }
 
+       public function manageSubscriptions(Request $request, $guild_id) {
+               $guild = DiscordGuild::where('guild_id', '=', $guild_id)->firstOrFail();
+               $this->authorize('manage', $guild);
+
+               $validatedData = $request->validate([
+                       'add_event' => 'numeric|exists:App\Models\Event,id',
+                       'add_user' => 'numeric|exists:App\Models\User,id',
+                       'remove_event' => 'numeric|exists:App\Models\Event,id',
+                       'remove_user' => 'numeric|exists:App\Models\User,id',
+               ]);
+
+               if (isset($validatedData['add_event'])) {
+                       $guild->event_subscriptions()->create(['event_id' => $validatedData['add_event']]);
+               }
+               if (isset($validatedData['add_user'])) {
+                       $guild->user_subscriptions()->create(['user_id' => $validatedData['add_user']]);
+               }
+               if (isset($validatedData['remove_event'])) {
+                       $guild->event_subscriptions()->where('event_id', '=', $validatedData['remove_event'])->delete();
+               }
+               if (isset($validatedData['remove_user'])) {
+                       $guild->user_subscriptions()->where('user_id', '=', $validatedData['remove_user'])->delete();
+               }
+               $guild->load([
+                       'event_subscriptions',
+                       'event_subscriptions.event',
+                       'user_subscriptions',
+                       'user_subscriptions.user',
+               ]);
+
+               return $guild->toJson();
+       }
+
 }
index c9a1a4190fb0e2c2c9b713879e98d826ec81d000..94db8055a79cf1cddec8e7909a0537c8c41b5933 100644 (file)
@@ -13,11 +13,18 @@ class EventController extends Controller
                $validatedData = $request->validate([
                        'after' => 'nullable|date',
                        'before' => 'nullable|date',
+                       'exclude_ids' => 'array|nullable',
+                       'exclude_ids.*' => 'int',
+                       'limit' => 'nullable|int',
                        'order' => 'nullable|string',
+                       'phrase' => 'nullable|string',
                        'with' => 'nullable|array',
                        'with.*' => 'string',
                ]);
                $events = Event::where('visible', '=', true);
+               if (!empty($validatedData['exclude_ids'])) {
+                       $events->whereNotIn('id', $validatedData['exclude_ids']);
+               }
                if (isset($validatedData['before'])) {
                        $events = $events->where(function ($query) use ($validatedData) {
                                $query->whereNull('start');
@@ -30,6 +37,9 @@ class EventController extends Controller
                                $query->orWhere('end', '>', $validatedData['after']);
                        });
                }
+               if (isset($validatedData['limit'])) {
+                       $events->limit($validatedData['limit']);
+               }
                if (isset($validatedData['order'])) {
                        switch ($validatedData['order']) {
                                case 'recency':
@@ -41,6 +51,9 @@ class EventController extends Controller
                                        break;
                        }
                }
+               if (isset($validatedData['phrase'])) {
+                       $events->where('title', 'LIKE', '%'.$validatedData['phrase'].'%');
+               }
                if (isset($validatedData['with'])) {
                        if (in_array('description', $validatedData['with'])) {
                                $events->with('description');
index 3fe68a532a6b9c88efdaed6f12d9b55b6af51bfa..f8b42831d21a465ac9190059364a32847dbd7503 100644 (file)
@@ -11,12 +11,17 @@ class UserController extends Controller
 
        public function search(Request $request) {
                $validatedData = $request->validate([
+                       'exclude_ids' => 'array|nullable',
+                       'exclude_ids.*' => 'string',
                        'phrase' => 'string|nullable',
                ]);
 
                $users = User::query();
+               if (!empty($validatedData['exclude_ids'])) {
+                       $users->whereNotIn('id', $validatedData['exclude_ids']);
+               }
                if (!empty($validatedData['phrase'])) {
-                       $users = $users->where('username', 'LIKE', '%'.$validatedData['phrase'].'%')
+                       $users->where('username', 'LIKE', '%'.$validatedData['phrase'].'%')
                                ->orWhere('nickname', 'LIKE', '%'.$validatedData['phrase'].'%');
                }
                $users = $users->limit(5);
@@ -25,7 +30,9 @@ class UserController extends Controller
 
        public function setLanguage(Request $request) {
                $user = $request->user();
-               if (!$user) return;
+               if (!$user) {
+                       return;
+               }
 
                $validatedData = $request->validate([
                        'language' => 'required|in:de,en',
index 7bc00022c3745d5127d27405fcc428f593ee798d..af73e9f382b80c364e6e0a2797cb8b30b6cd1b8c 100644 (file)
@@ -26,4 +26,9 @@ class DiscordGuildEventSubscription extends Model {
                return $this->belongsTo(DiscordGuild::class);
        }
 
+       protected $fillable = [
+               'discord_guild_id',
+               'event_id',
+       ];
+
 }
index 4e826de0784ca7bdd373bc3f7102231074fb7f27..fa9811a3ef7582ce075a12d3af8ff83e917cb9b8 100644 (file)
@@ -26,4 +26,13 @@ class DiscordGuildUserSubscription extends Model {
                return $this->belongsTo(User::class);
        }
 
+       protected $fillable = [
+               'discord_guild_id',
+               'user_id',
+       ];
+
+       protected $casts = [
+               'user_id' => 'string',
+       ];
+
 }
diff --git a/database/migrations/2025_07_07_112614_discord_subscriptions_unique.php b/database/migrations/2025_07_07_112614_discord_subscriptions_unique.php
new file mode 100644 (file)
index 0000000..e3fafa0
--- /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.
+        */
+       public function up(): void {
+               Schema::table('discord_guild_event_subscriptions', function (Blueprint $table) {
+                       $table->unique(['discord_guild_id', 'event_id'], 'guild_event_unique');
+               });
+               Schema::table('discord_guild_user_subscriptions', function (Blueprint $table) {
+                       $table->unique(['discord_guild_id', 'user_id'], 'guild_user_unique');
+               });
+       }
+
+       /**
+        * Reverse the migrations.
+        */
+       public function down(): void {
+               Schema::table('discord_guild_event_subscriptions', function (Blueprint $table) {
+                       $table->dropIndex('guild_event_unique');
+               });
+               Schema::table('discord_guild_user_subscriptions', function (Blueprint $table) {
+                       $table->dropIndex('guild_user_unique');
+               });
+       }
+};
index 653db1aa8c781f07a9ff174683448528c0a2ed52..f72662066aca95f75a6e88abc3f84f9350d4140e 100644 (file)
@@ -91,7 +91,7 @@ const DiscordChannelSelect = ({
                        </Button>
                </div>;
        }
-       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+       return <div className={`model-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
                <Form.Control
                        className="search-input"
                        name={Math.random().toString(20).substr(2, 10)}
index 9ac7b5689cd41d769d768b6dc87e3f19c12beca7..49b759506bddff0283e700db86e42c91ca7d5c14 100644 (file)
@@ -81,7 +81,7 @@ const DiscordSelect = ({ onChange, value }) => {
                        </Button>
                </div>;
        }
-       return <div className={`discord-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+       return <div className={`model-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
                <Form.Control
                        className="search-input"
                        name={Math.random().toString(20).substr(2, 10)}
diff --git a/resources/js/components/common/EventSelect.jsx b/resources/js/components/common/EventSelect.jsx
new file mode 100644 (file)
index 0000000..8e10e26
--- /dev/null
@@ -0,0 +1,124 @@
+import axios from 'axios';
+import PropTypes from 'prop-types';
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Button, Form, ListGroup } from 'react-bootstrap';
+
+import Icon from './Icon';
+import EventBox from '../events/Box';
+import debounce from '../../helpers/debounce';
+
+const EventSelect = ({ excludeIds = [], name, onChange, value }) => {
+       const [resolved, setResolved] = useState(null);
+       const [results, setResults] = useState([]);
+       const [search, setSearch] = useState('');
+       const [showResults, setShowResults] = useState(false);
+
+       const ref = useRef(null);
+
+       useEffect(() => {
+               const handleEventOutside = e => {
+                       if (ref.current && !ref.current.contains(e.target)) {
+                               setShowResults(false);
+                       }
+               };
+               document.addEventListener('mousedown', handleEventOutside, true);
+               document.addEventListener('focus', handleEventOutside, true);
+               return () => {
+                       document.removeEventListener('mousedown', 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/events`, {
+                               params: {
+                                       exclude_ids: excludeIds,
+                                       limit: 5,
+                                       phrase,
+                               },
+                               signal: ctrl.signal,
+                       });
+                       ctrl = null;
+                       setResults(response.data);
+                       if (phrase) {
+                               setShowResults(true);
+                       }
+               } catch (e) {
+                       ctrl = null;
+                       console.error(e);
+               }
+       }, 300), [excludeIds]);
+
+       useEffect(() => {
+               fetch(search);
+       }, [search]);
+
+       useEffect(() => {
+               if (value) {
+                       axios
+                               .get(`/api/events/${value}`)
+                       .then(response => {
+                               setResolved(response.data);
+                       });
+               } else {
+                       setResolved(null);
+               }
+       }, [value]);
+
+       if (value) {
+               return <div className="d-flex justify-content-between">
+                       {resolved ? <EventBox event={resolved} /> : <span>value</span>}
+                       <Button
+                               onClick={() => onChange({ target: { name, value: null }})}
+                               size="sm"
+                               variant="outline-danger"
+                       >
+                               <Icon.REMOVE />
+                       </Button>
+               </div>;
+       }
+       return <div className={`model-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">
+                       <ListGroup className="search-results">
+                               {results.map(result =>
+                                       <ListGroup.Item
+                                               action
+                                               key={result.id}
+                                               onClick={() => {
+                                                       onChange({
+                                                               target: { name, value: result.id },
+                                                       });
+                                                       setSearch('');
+                                                       setShowResults(false);
+                                               }}
+                                       >
+                                               <EventBox event={result} />
+                                       </ListGroup.Item>
+                               )}
+                       </ListGroup>
+               </div>
+       </div>;
+};
+
+EventSelect.propTypes = {
+       excludeIds: PropTypes.arrayOf(PropTypes.number),
+       name: PropTypes.string,
+       onChange: PropTypes.func,
+       value: PropTypes.string,
+};
+
+export default EventSelect;
index 32bd186f3e82ecc10c9c84b96ab59cee25f0b4fd..2889cc0ba763c841b8366d08c774a6c0508a6589 100644 (file)
@@ -3,11 +3,11 @@ import PropTypes from 'prop-types';
 import React, { useCallback, useEffect, useRef, useState } from 'react';
 import { Button, Form, ListGroup } from 'react-bootstrap';
 
-import Icon from '../common/Icon';
+import Icon from './Icon';
 import UserBox from '../users/Box';
 import debounce from '../../helpers/debounce';
 
-const UserSelect = ({ name, onChange, value }) => {
+const UserSelect = ({ excludeIds = [], name, onChange, value }) => {
        const [resolved, setResolved] = useState(null);
        const [results, setResults] = useState([]);
        const [search, setSearch] = useState('');
@@ -42,17 +42,21 @@ const UserSelect = ({ name, onChange, value }) => {
                try {
                        const response = await axios.get(`/api/users`, {
                                params: {
+                                       exclude_ids: excludeIds,
                                        phrase,
                                },
                                signal: ctrl.signal,
                        });
                        ctrl = null;
                        setResults(response.data);
+                       if (phrase) {
+                               setShowResults(true);
+                       }
                } catch (e) {
                        ctrl = null;
                        console.error(e);
                }
-       }, 300), []);
+       }, 300), [excludeIds]);
 
        useEffect(() => {
                fetch(search);
@@ -82,7 +86,7 @@ const UserSelect = ({ name, onChange, value }) => {
                        </Button>
                </div>;
        }
-       return <div className={`user-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
+       return <div className={`model-select ${showResults ? 'expanded' : 'collapsed'}`} ref={ref}>
                <Form.Control
                        className="search-input"
                        name={Math.random().toString(20).substr(2, 10)}
@@ -97,9 +101,13 @@ const UserSelect = ({ name, onChange, value }) => {
                                        <ListGroup.Item
                                                action
                                                key={result.id}
-                                               onClick={() => onChange({
-                                                       target: { name, value: result.id },
-                                               })}
+                                               onClick={() => {
+                                                       onChange({
+                                                               target: { name, value: result.id },
+                                                       });
+                                                       setSearch('');
+                                                       setShowResults(false);
+                                               }}
                                        >
                                                <UserBox discriminator noLink user={result} />
                                        </ListGroup.Item>
@@ -110,6 +118,7 @@ const UserSelect = ({ name, onChange, value }) => {
 };
 
 UserSelect.propTypes = {
+       excludeIds: PropTypes.arrayOf(PropTypes.string),
        name: PropTypes.string,
        onChange: PropTypes.func,
        value: PropTypes.string,
diff --git a/resources/js/components/discord-bot/EventSubscriptions.jsx b/resources/js/components/discord-bot/EventSubscriptions.jsx
new file mode 100644 (file)
index 0000000..0fe140d
--- /dev/null
@@ -0,0 +1,47 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import EventSelect from '../common/EventSelect';
+import Icon from '../common/Icon';
+
+const EventSubscriptions = ({ addEvent, removeEvent, subs }) => {
+       const { t } = useTranslation();
+
+       return <div>
+               <Form.Group controlId="esubs.addEvent">
+                       <Form.Label>{t('discordBot.addEvent')}</Form.Label>
+                       <Form.Control
+                               as={EventSelect}
+                               excludeIds={subs.map(s => s.event_id)}
+                               onChange={e => addEvent(e.target.value)}
+                               value=""
+                       />
+               </Form.Group>
+               {subs.map((esub) => (
+                       <div className="d-flex align-items-center justify-content-between my-2" key={esub.id}>
+                               <div>{esub.event.title}</div>
+                               <div className="button-bar">
+                                       <Button
+                                               onClick={() => removeEvent(esub.event_id)}
+                                               size="sm"
+                                               title={t('button.remove')}
+                                               variant="outline-danger"
+                                       >
+                                               <Icon.DELETE title="" />
+                                       </Button>
+                               </div>
+                       </div>
+               ))}
+       </div>;
+};
+
+EventSubscriptions.propTypes = {
+       addEvent: PropTypes.func,
+       removeEvent: PropTypes.func,
+       subs: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+export default EventSubscriptions;
index b7255800f38653a3acf50386562421aa159832a3..02b563b67eecfb174fccf1b4620b8b84c7eb1379 100644 (file)
@@ -3,8 +3,14 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import { Col, Row } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
+import toastr from 'toastr';
 
+import EventSubscriptions from './EventSubscriptions';
 import GuildProtocol from './GuildProtocol';
+import UserSubscriptions from './UserSubscriptions';
+import ErrorBoundary from '../common/ErrorBoundary';
+import { compareTitle } from '../../helpers/Event';
+import { compareUsername } from '../../helpers/User';
 
 const GuildControls = ({ guild }) => {
        const [protocol, setProtocol] = React.useState([]);
@@ -12,6 +18,51 @@ const GuildControls = ({ guild }) => {
 
        const { t } = useTranslation();
 
+       const addEventSub = React.useCallback(async (event_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+                               add_event: event_id,
+                       });
+                       setSubscriptions(response.data);
+               } catch (e) {
+                       toastr.error(t('discordBot.eventSubError'));
+               }
+       }, [guild.guild_id, t]);
+
+       const removeEventSub = React.useCallback(async (event_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+                               remove_event: event_id,
+                       });
+                       setSubscriptions(response.data);
+               } catch (e) {
+                       toastr.error(t('discordBot.eventUnsubError'));
+               }
+       }, [guild.guild_id, t]);
+
+
+       const addUserSub = React.useCallback(async (user_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+                               add_user: user_id,
+                       });
+                       setSubscriptions(response.data);
+               } catch (e) {
+                       toastr.error(t('discordBot.userSubError'));
+               }
+       }, [guild.guild_id, t]);
+
+       const removeUserSub = React.useCallback(async (user_id) => {
+               try {
+                       const response = await axios.post(`/api/discord-guilds/${guild.guild_id}/subscriptions`, {
+                               remove_user: user_id,
+                       });
+                       setSubscriptions(response.data);
+               } catch (e) {
+                       toastr.error(t('discordBot.userUnsubError'));
+               }
+       }, [guild.guild_id, t]);
+
        React.useEffect(() => {
                const ctrl = new AbortController();
                axios
@@ -22,6 +73,12 @@ const GuildControls = ({ guild }) => {
                axios
                        .get(`/api/discord-guilds/${guild.guild_id}/subscriptions`, { signal: ctrl.signal })
                        .then(response => {
+                               response.data.event_subscriptions.sort((a, b) => {
+                                       return compareTitle(a.event, b.event);
+                               });
+                               response.data.user_subscriptions.sort((a, b) => {
+                                       return compareUsername(a.user, b.user);
+                               });
                                setSubscriptions(response.data);
                        });
                window.Echo.private(`DiscordGuild.${guild.id}`)
@@ -41,25 +98,37 @@ const GuildControls = ({ guild }) => {
                };
        }, [guild.id]);
 
-       return <section className="mt-5">
-               <h2>{t('discordBot.guildControls')}</h2>
-               <Row>
-                       <Col md={6}>
-                               <h3>{t('discordBot.eventSubscriptions')}</h3>
-                               {subscriptions.event_subscriptions ? subscriptions.event_subscriptions.map(esub =>
-                                       <div key={esub.id}>{esub.event.title}</div>
-                               ): null}
-                       </Col>
-                       <Col md={6}>
-                               <h3>{t('discordBot.userSubscriptions')}</h3>
-                               {subscriptions.user_subscriptions ? subscriptions.user_subscriptions.map(usub =>
-                                       <div key={usub.id}>{usub.user.username}</div>
-                               ): null}
-                       </Col>
-               </Row>
-               <h3>{t('discordBot.guildProtocol')}</h3>
-               <GuildProtocol protocol={protocol} />
-       </section>;
+       return <>
+               <section className="mt-5">
+                       <h2>{t('discordBot.guildControls')}</h2>
+                       <Row>
+                               <Col md={6}>
+                                       <h3>{t('discordBot.eventSubscriptions')}</h3>
+                                       <ErrorBoundary>
+                                               <EventSubscriptions
+                                                       addEvent={addEventSub}
+                                                       removeEvent={removeEventSub}
+                                                       subs={subscriptions.event_subscriptions || []}
+                                               />
+                                       </ErrorBoundary>
+                               </Col>
+                               <Col md={6}>
+                                       <h3>{t('discordBot.userSubscriptions')}</h3>
+                                       <ErrorBoundary>
+                                               <UserSubscriptions
+                                                       addUser={addUserSub}
+                                                       removeUser={removeUserSub}
+                                                       subs={subscriptions.user_subscriptions || []}
+                                               />
+                                       </ErrorBoundary>
+                               </Col>
+                       </Row>
+               </section>
+               <section className="mt-5">
+                       <h3>{t('discordBot.guildProtocol')}</h3>
+                       <GuildProtocol protocol={protocol} />
+               </section>
+       </>;
 };
 
 GuildControls.propTypes = {
diff --git a/resources/js/components/discord-bot/UserSubscriptions.jsx b/resources/js/components/discord-bot/UserSubscriptions.jsx
new file mode 100644 (file)
index 0000000..390773c
--- /dev/null
@@ -0,0 +1,48 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Form } from 'react-bootstrap';
+import { useTranslation } from 'react-i18next';
+
+import UserSelect from '../common/UserSelect';
+import Icon from '../common/Icon';
+import UserBox from '../users/Box';
+
+const UserSubscriptions = ({ addUser, removeUser, subs }) => {
+       const { t } = useTranslation();
+
+       return <div>
+               <Form.Group controlId="usubs.addUser">
+                       <Form.Label>{t('discordBot.addUser')}</Form.Label>
+                       <Form.Control
+                               as={UserSelect}
+                               excludeIds={subs.map(s => s.user_id)}
+                               onChange={e => addUser(e.target.value)}
+                               value=""
+                       />
+               </Form.Group>
+               {subs.map((usub) => (
+                       <div className="d-flex align-items-center justify-content-between my-2" key={usub.id}>
+                               <UserBox user={usub.user} />
+                               <div className="button-bar">
+                                       <Button
+                                               onClick={() => removeUser(usub.user_id)}
+                                               size="sm"
+                                               title={t('button.remove')}
+                                               variant="outline-danger"
+                                       >
+                                               <Icon.DELETE title="" />
+                                       </Button>
+                               </div>
+                       </div>
+               ))}
+       </div>;
+};
+
+UserSubscriptions.propTypes = {
+       addUser: PropTypes.func,
+       removeUser: PropTypes.func,
+       subs: PropTypes.arrayOf(PropTypes.shape({
+       })),
+};
+
+export default UserSubscriptions;
diff --git a/resources/js/components/events/Box.jsx b/resources/js/components/events/Box.jsx
new file mode 100644 (file)
index 0000000..a6e11f6
--- /dev/null
@@ -0,0 +1,16 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+const Box = ({ event }) => {
+       return <div className="event-box">
+               <span>{event.title}</span>
+       </div>;
+};
+
+Box.propTypes = {
+       event: PropTypes.shape({
+               title: PropTypes.string,
+       }),
+};
+
+export default Box;
index c39daaab01ceff30f3d494479a79177ff6c56292..9fd632442bf33b86bdb1327048b847b3c5ae7050 100644 (file)
@@ -1,5 +1,8 @@
 import moment from 'moment';
 
+import { getTranslation } from './Technique';
+import i18n from '../i18n';
+
 export const getLink = event => `/events/${event.name}`;
 
 export const hasConcluded = event => event && event.end && moment(event.end).isBefore(moment());
@@ -27,3 +30,13 @@ export const compareStart = (a, b) => {
        }
        return 0;
 };
+
+const getTitle = (event) =>
+       (event.description && getTranslation(event.description, 'title', i18n.language))
+               || event.title;
+
+export const compareTitle = (a, b) => {
+       const a_title = getTitle(a);
+       const b_title = getTitle(b);
+       return a_title.localeCompare(b_title);
+};
index b972766a01acbe96f7f31c763eed42fa82337e40..083251440a2d1a72c115d25626d17b18c0291fea 100644 (file)
@@ -138,6 +138,8 @@ export default {
                        },
                },
                discordBot: {
+                       addEvent: 'Event abonnieren',
+                       addUser: 'User abonnieren',
                        channel: 'Kanal',
                        channelControls: 'Kanal-Steuerung',
                        commandStatus: {
@@ -154,7 +156,9 @@ export default {
                                result: 'Ergebnis',
                        },
                        controls: 'Steuerung',
+                       eventSubError: 'Fehler bim Abonnieren',
                        eventSubscriptions: 'Event Subscriptions',
+                       eventUnsubError: 'Fehler beim Kündigen',
                        guild: 'Server',
                        guildControls: 'Server-Steuerung',
                        guildProtocol: 'Command Protokoll',
@@ -165,7 +169,9 @@ export default {
                        messageSuccess: 'Nachricht in Warteschlange',
                        selectGuild: 'Bitte Server wählen',
                        sendMessage: 'Nachricht senden',
+                       userSubError: 'Fehler beim Abonnieren',
                        userSubscriptions: 'User Subscriptions',
+                       userUnsubError: 'Fehler beim Kündigen',
                },
                episodes: {
                        addRestream: 'Neuer Restream',
index 72ac2345301eee8858dac46bcde0a63885321b97..ae4e45799a3316ed7f286f5f27c2bec81aa90c72 100644 (file)
@@ -138,6 +138,8 @@ export default {
                        },
                },
                discordBot: {
+                       addEvent: 'Subscribe to event',
+                       addUser: 'Subscribe to user',
                        channel: 'Channel',
                        channelControls: 'Channel controls',
                        commandStatus: {
@@ -154,7 +156,9 @@ export default {
                                result: 'Result',
                        },
                        controls: 'Controls',
+                       eventSubError: 'Error subscribing',
                        eventSubscriptions: 'Event subscriptions',
+                       eventUnsubError: 'Error unsubscribing',
                        guild: 'Server',
                        guildControls: 'Server controls',
                        guildProtocol: 'Command protocol',
@@ -165,7 +169,9 @@ export default {
                        messageSuccess: 'Message queued',
                        selectGuild: 'Please select server',
                        sendMessage: 'Send message',
+                       userSubError: 'Error subscribing',
                        userSubscriptions: 'User subscriptions',
+                       userUnsubError: 'Error unsubscribing',
                },
                episodes: {
                        addRestream: 'Add Restream',
index bcede8f6aedea30159c2456803eb80b399ad4d10..c66b740dc45e44fbfe8cd3f020a63aa432bc6b1f 100644 (file)
@@ -1,26 +1,3 @@
-.discord-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;
-       }
-}
-
 .channel-box {
        > svg {
                margin-right: 0.25rem;
index 411188ff2eba90c3252cdc7bf45d030ace06a1fb..6d95d6484efe8dc89651323a33dc28449166f2af 100644 (file)
@@ -80,3 +80,31 @@ label {
                }
        }
 }
+
+.model-select {
+       .search-results-holder {
+               position: relative;
+       }
+       .search-results {
+               position: absolute;
+               top: 100%;
+               left: 0;
+               z-index: 4; /* active pagination links have z-index 3 for some reason */
+               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 7a31fa78b8463752951c0af3f6e2e3cae7dccbde..6f96a37990674448f66690b01e2c41208b5734db 100644 (file)
@@ -55,6 +55,7 @@ Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search
 Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildController@single');
 Route::get('discord-guilds/{guild_id}/channels', 'App\Http\Controllers\DiscordChannelController@search');
 Route::get('discord-guilds/{guild_id}/subscriptions', 'App\Http\Controllers\DiscordGuildController@subscriptions');
+Route::post('discord-guilds/{guild_id}/subscriptions', 'App\Http\Controllers\DiscordGuildController@manageSubscriptions');
 
 Route::get('episodes', 'App\Http\Controllers\EpisodeController@search');
 Route::post('episodes/{episode}/add-restream', 'App\Http\Controllers\EpisodeController@addRestream');