From: Daniel Karbach Date: Mon, 7 Jul 2025 11:36:05 +0000 (+0200) Subject: discord event subscription management X-Git-Url: http://git.localhorst.tv/?a=commitdiff_plain;h=526e10cac92b5667146ecdd1007f9d6a69b786c5;p=alttp.git discord event subscription management --- diff --git a/app/Http/Controllers/DiscordGuildController.php b/app/Http/Controllers/DiscordGuildController.php index 3b4a02a..6c1e221 100644 --- a/app/Http/Controllers/DiscordGuildController.php +++ b/app/Http/Controllers/DiscordGuildController.php @@ -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(); + } + } diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index c9a1a41..94db805 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -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'); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 3fe68a5..f8b4283 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -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', diff --git a/app/Models/DiscordGuildEventSubscription.php b/app/Models/DiscordGuildEventSubscription.php index 7bc0002..af73e9f 100644 --- a/app/Models/DiscordGuildEventSubscription.php +++ b/app/Models/DiscordGuildEventSubscription.php @@ -26,4 +26,9 @@ class DiscordGuildEventSubscription extends Model { return $this->belongsTo(DiscordGuild::class); } + protected $fillable = [ + 'discord_guild_id', + 'event_id', + ]; + } diff --git a/app/Models/DiscordGuildUserSubscription.php b/app/Models/DiscordGuildUserSubscription.php index 4e826de..fa9811a 100644 --- a/app/Models/DiscordGuildUserSubscription.php +++ b/app/Models/DiscordGuildUserSubscription.php @@ -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 index 0000000..e3fafa0 --- /dev/null +++ b/database/migrations/2025_07_07_112614_discord_subscriptions_unique.php @@ -0,0 +1,32 @@ +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'); + }); + } +}; diff --git a/resources/js/components/common/DiscordChannelSelect.jsx b/resources/js/components/common/DiscordChannelSelect.jsx index 653db1a..f726620 100644 --- a/resources/js/components/common/DiscordChannelSelect.jsx +++ b/resources/js/components/common/DiscordChannelSelect.jsx @@ -91,7 +91,7 @@ const DiscordChannelSelect = ({ ; } - return
+ return
{
; } - return
+ return
{ + 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
+ {resolved ? : value} + +
; + } + return
+ setSearch(e.target.value)} + onFocus={() => setShowResults(true)} + type="search" + value={search} + /> +
+ + {results.map(result => + { + onChange({ + target: { name, value: result.id }, + }); + setSearch(''); + setShowResults(false); + }} + > + + + )} + +
+
; +}; + +EventSelect.propTypes = { + excludeIds: PropTypes.arrayOf(PropTypes.number), + name: PropTypes.string, + onChange: PropTypes.func, + value: PropTypes.string, +}; + +export default EventSelect; diff --git a/resources/js/components/common/UserSelect.jsx b/resources/js/components/common/UserSelect.jsx index 32bd186..2889cc0 100644 --- a/resources/js/components/common/UserSelect.jsx +++ b/resources/js/components/common/UserSelect.jsx @@ -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 }) => {
; } - return
+ return
{ onChange({ - target: { name, value: result.id }, - })} + onClick={() => { + onChange({ + target: { name, value: result.id }, + }); + setSearch(''); + setShowResults(false); + }} > @@ -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 index 0000000..0fe140d --- /dev/null +++ b/resources/js/components/discord-bot/EventSubscriptions.jsx @@ -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
+ + {t('discordBot.addEvent')} + s.event_id)} + onChange={e => addEvent(e.target.value)} + value="" + /> + + {subs.map((esub) => ( +
+
{esub.event.title}
+
+ +
+
+ ))} +
; +}; + +EventSubscriptions.propTypes = { + addEvent: PropTypes.func, + removeEvent: PropTypes.func, + subs: PropTypes.arrayOf(PropTypes.shape({ + })), +}; + +export default EventSubscriptions; diff --git a/resources/js/components/discord-bot/GuildControls.jsx b/resources/js/components/discord-bot/GuildControls.jsx index b725580..02b563b 100644 --- a/resources/js/components/discord-bot/GuildControls.jsx +++ b/resources/js/components/discord-bot/GuildControls.jsx @@ -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
-

{t('discordBot.guildControls')}

- - -

{t('discordBot.eventSubscriptions')}

- {subscriptions.event_subscriptions ? subscriptions.event_subscriptions.map(esub => -
{esub.event.title}
- ): null} - - -

{t('discordBot.userSubscriptions')}

- {subscriptions.user_subscriptions ? subscriptions.user_subscriptions.map(usub => -
{usub.user.username}
- ): null} - -
-

{t('discordBot.guildProtocol')}

- -
; + return <> +
+

{t('discordBot.guildControls')}

+ + +

{t('discordBot.eventSubscriptions')}

+ + + + + +

{t('discordBot.userSubscriptions')}

+ + + + +
+
+
+

{t('discordBot.guildProtocol')}

+ +
+ ; }; GuildControls.propTypes = { diff --git a/resources/js/components/discord-bot/UserSubscriptions.jsx b/resources/js/components/discord-bot/UserSubscriptions.jsx new file mode 100644 index 0000000..390773c --- /dev/null +++ b/resources/js/components/discord-bot/UserSubscriptions.jsx @@ -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
+ + {t('discordBot.addUser')} + s.user_id)} + onChange={e => addUser(e.target.value)} + value="" + /> + + {subs.map((usub) => ( +
+ +
+ +
+
+ ))} +
; +}; + +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 index 0000000..a6e11f6 --- /dev/null +++ b/resources/js/components/events/Box.jsx @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +const Box = ({ event }) => { + return
+ {event.title} +
; +}; + +Box.propTypes = { + event: PropTypes.shape({ + title: PropTypes.string, + }), +}; + +export default Box; diff --git a/resources/js/helpers/Event.js b/resources/js/helpers/Event.js index c39daaa..9fd6324 100644 --- a/resources/js/helpers/Event.js +++ b/resources/js/helpers/Event.js @@ -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); +}; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index b972766..0832514 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index 72ac234..ae4e457 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -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', diff --git a/resources/sass/discord.scss b/resources/sass/discord.scss index bcede8f..c66b740 100644 --- a/resources/sass/discord.scss +++ b/resources/sass/discord.scss @@ -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; diff --git a/resources/sass/form.scss b/resources/sass/form.scss index 411188f..6d95d64 100644 --- a/resources/sass/form.scss +++ b/resources/sass/form.scss @@ -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; + } + } +} diff --git a/routes/api.php b/routes/api.php index 7a31fa7..6f96a37 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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');