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();
+ }
+
}
$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');
$query->orWhere('end', '>', $validatedData['after']);
});
}
+ if (isset($validatedData['limit'])) {
+ $events->limit($validatedData['limit']);
+ }
if (isset($validatedData['order'])) {
switch ($validatedData['order']) {
case 'recency':
break;
}
}
+ if (isset($validatedData['phrase'])) {
+ $events->where('title', 'LIKE', '%'.$validatedData['phrase'].'%');
+ }
if (isset($validatedData['with'])) {
if (in_array('description', $validatedData['with'])) {
$events->with('description');
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);
public function setLanguage(Request $request) {
$user = $request->user();
- if (!$user) return;
+ if (!$user) {
+ return;
+ }
$validatedData = $request->validate([
'language' => 'required|in:de,en',
return $this->belongsTo(DiscordGuild::class);
}
+ protected $fillable = [
+ 'discord_guild_id',
+ 'event_id',
+ ];
+
}
return $this->belongsTo(User::class);
}
+ protected $fillable = [
+ 'discord_guild_id',
+ 'user_id',
+ ];
+
+ protected $casts = [
+ 'user_id' => 'string',
+ ];
+
}
--- /dev/null
+<?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');
+ });
+ }
+};
</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)}
</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)}
--- /dev/null
+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;
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('');
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);
</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)}
<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>
};
UserSelect.propTypes = {
+ excludeIds: PropTypes.arrayOf(PropTypes.string),
name: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.string,
--- /dev/null
+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;
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([]);
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
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}`)
};
}, [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 = {
--- /dev/null
+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;
--- /dev/null
+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;
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());
}
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);
+};
},
},
discordBot: {
+ addEvent: 'Event abonnieren',
+ addUser: 'User abonnieren',
channel: 'Kanal',
channelControls: 'Kanal-Steuerung',
commandStatus: {
result: 'Ergebnis',
},
controls: 'Steuerung',
+ eventSubError: 'Fehler bim Abonnieren',
eventSubscriptions: 'Event Subscriptions',
+ eventUnsubError: 'Fehler beim Kündigen',
guild: 'Server',
guildControls: 'Server-Steuerung',
guildProtocol: 'Command Protokoll',
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',
},
},
discordBot: {
+ addEvent: 'Subscribe to event',
+ addUser: 'Subscribe to user',
channel: 'Channel',
channelControls: 'Channel controls',
commandStatus: {
result: 'Result',
},
controls: 'Controls',
+ eventSubError: 'Error subscribing',
eventSubscriptions: 'Event subscriptions',
+ eventUnsubError: 'Error unsubscribing',
guild: 'Server',
guildControls: 'Server controls',
guildProtocol: 'Command protocol',
messageSuccess: 'Message queued',
selectGuild: 'Please select server',
sendMessage: 'Send message',
+ userSubError: 'Error subscribing',
userSubscriptions: 'User subscriptions',
+ userUnsubError: 'Error unsubscribing',
},
episodes: {
addRestream: 'Add Restream',
-.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;
}
}
}
+
+.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;
+ }
+ }
+}
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');