mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-20 02:00:49 +00:00
Clean slate!
This commit is contained in:
parent
d13e47d3ce
commit
dc39bc0cd9
28 changed files with 4 additions and 4604 deletions
|
@ -11,7 +11,6 @@
|
|||
"@types/react": "^17.0.5",
|
||||
"@types/react-dom": "^17.0.4",
|
||||
"@vitejs/plugin-react": "^1.0.9",
|
||||
"bulma": "^0.9.2",
|
||||
"i18next": "^20.6.1",
|
||||
"pretty-ms": "^7.0.1",
|
||||
"react": "^17.0.2",
|
||||
|
|
|
@ -1,260 +1 @@
|
|||
{
|
||||
"pages": {
|
||||
"/": "Home",
|
||||
"/http": "Web server",
|
||||
"/twitch": "Twitch integration",
|
||||
"/twitch/settings": "Module configuration",
|
||||
"/twitch/bot/settings": "Bot configuration",
|
||||
"/twitch/bot/commands": "Bot commands",
|
||||
"/twitch/bot/timers": "Bot timers",
|
||||
"/twitch/bot/alerts": "Chat alerts",
|
||||
"/loyalty": "Loyalty points",
|
||||
"/loyalty/settings": "Configuration",
|
||||
"/loyalty/users": "Viewer points",
|
||||
"/loyalty/queue": "Redempions",
|
||||
"/loyalty/rewards": "Rewards",
|
||||
"/loyalty/goals": "Goals",
|
||||
"/stulbe": "Back-end integration",
|
||||
"/stulbe/config": "Back-end configuration",
|
||||
"/stulbe/webhooks": "Webhooks (for alerts)"
|
||||
},
|
||||
"system": {
|
||||
"menu-header": "Navigation",
|
||||
"loading": "Loading…",
|
||||
"connection-lost": "Connection to server was lost, retrying...",
|
||||
"pagination": {
|
||||
"page": "Page {{page}}",
|
||||
"gotopage": "Go to page {{page}}"
|
||||
}
|
||||
},
|
||||
"debug": {
|
||||
"read-key": "Read key",
|
||||
"read-btn": "Read",
|
||||
"write-key": "Write key",
|
||||
"write-btn": "Write",
|
||||
"fix-json-btn": "Fix JSON",
|
||||
"console-header": "Console operations",
|
||||
"get-keys": "Get all keys",
|
||||
"get-all": "DUMP ALL",
|
||||
"friendly-greeting": "WELCOME TO HELL"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"disable": "Disable",
|
||||
"enable": "Enable",
|
||||
"create": "Create",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK",
|
||||
"test": "Test"
|
||||
},
|
||||
"http": {
|
||||
"header": "Web server configuration",
|
||||
"server-bind": "HTTP server bind",
|
||||
"static-content": "Static content",
|
||||
"enable-static": "Enable static server",
|
||||
"static-root-path": "Static content root path",
|
||||
"kv-password": "Kilovolt password"
|
||||
},
|
||||
"backend": {
|
||||
"config": {
|
||||
"header": "Back-end integration settings",
|
||||
"enable": "Enable back-end (stulbe) integration",
|
||||
"endpoint": "Stulbe Endpoint",
|
||||
"username": "User name",
|
||||
"username-placeholder": "myUserName",
|
||||
"auth-key": "Authorization key",
|
||||
"auth-key-placeholder": "key goes here",
|
||||
"authenticated": "Authenticated as",
|
||||
"auth-success-message": "Connection/Authentication was successful!"
|
||||
},
|
||||
"webhook": {
|
||||
"header": "Webhook subscriptions",
|
||||
"err-not-enabled": "Back-end integration must be enabled for this to work",
|
||||
"loading": "Querying user data from backend…",
|
||||
"err-no-user": "No twitch user is currently associated (and therefore webhooks are disabled!)",
|
||||
"current-status": "Current status:",
|
||||
"auth-message": "Click the following button to authenticate the back-end with your Twitch account:",
|
||||
"auth-button": "Authenticate with Twitch",
|
||||
"fake-header": "Simulate events",
|
||||
"sim-channel.update": "Channel update",
|
||||
"sim-channel.follow": "New follow",
|
||||
"sim-channel.subscribe": "New sub",
|
||||
"sim-channel.subscription.gift": "Gift sub",
|
||||
"sim-channel.subscription.message": "Re-sub with message",
|
||||
"sim-channel.cheer": "Cheer",
|
||||
"sim-channel.raid": "Raid"
|
||||
}
|
||||
},
|
||||
"loyalty": {
|
||||
"goals": {
|
||||
"reached": "Reached!",
|
||||
"contributors": "Contributors:",
|
||||
"no-contributors": "No one has contributed yet :(",
|
||||
"id": "Goal ID",
|
||||
"id-placeholder": "goal_id_here",
|
||||
"id-help": "Choose a simple name that can be referenced by other software. It will be auto-generated from the goal name if you leave it blank.",
|
||||
"err-goalid-dup": "There is already a goal with this ID! Please choose a different one.",
|
||||
"name": "Name",
|
||||
"name-placeholder": "My dream goal",
|
||||
"icon": "Icon",
|
||||
"icon-placeholder": "Image URL",
|
||||
"description": "Description",
|
||||
"description-placeholder": "What's gonna happen when we reach this goal?",
|
||||
"header": "Community goals",
|
||||
"new": "New goal",
|
||||
"search": "Search by name",
|
||||
"modify": "Modify goal"
|
||||
},
|
||||
"points": "Points",
|
||||
"points-fallback": "points",
|
||||
"queue": {
|
||||
"header": "Redemption queue",
|
||||
"search": "Search by username",
|
||||
"reward-name": "Reward name",
|
||||
"request": "Request",
|
||||
"accept": "Accept",
|
||||
"refund": "Refund",
|
||||
"err-not-available": "Redemption queue is not available (loyalty disabled or no one has redeemed anything yet)"
|
||||
},
|
||||
"rewards": {
|
||||
"cooldown": "Cooldown",
|
||||
"required-info": "Required info",
|
||||
"id": "Reward ID",
|
||||
"id-placeholder": "reward_id_here",
|
||||
"err-rewid-dup": "There is already a reward with this ID! Please choose a different one.",
|
||||
"id-help": "Choose a simple name that can be referenced by other software. It will be auto-generated from the reward name if you leave it blank.",
|
||||
"name": "Name",
|
||||
"name-placeholder": "My awesome reward",
|
||||
"icon": "Icon",
|
||||
"icon-placeholder": "Image URL",
|
||||
"description": "Description",
|
||||
"description-placeholder": "What's so cool about this reward?",
|
||||
"cost": "Cost",
|
||||
"requires-extra-info": "Requires viewer-specified details",
|
||||
"extra-info": "Required info",
|
||||
"extra-info-placeholder": "What extra detail to ask the viewer for",
|
||||
"header": "Loyalty rewards",
|
||||
"new-reward": "New reward",
|
||||
"search": "Search by name",
|
||||
"modify-reward": "Modify reward"
|
||||
},
|
||||
"config": {
|
||||
"header": "Loyalty system configuration",
|
||||
"enable": "Enable loyalty points",
|
||||
"err-twitchbot-disabled": "(Twitch bot must be enabled for this!)",
|
||||
"currency-name": "Currency name",
|
||||
"bonus-points": "Bonus points for active users",
|
||||
"points-every": "every",
|
||||
"give-points": "Give",
|
||||
"point-reward-frequency": "How often to give {{points}}"
|
||||
},
|
||||
"userlist": {
|
||||
"give-points": "Give points to user",
|
||||
"modify-balance": "Modify balance",
|
||||
"give-button": "Give",
|
||||
"userlist-header": "All viewers with {{currency}}",
|
||||
"err-not-available": "Viewer list is not available (loyalty disabled or no one has points)"
|
||||
}
|
||||
},
|
||||
"form-common": {
|
||||
"required": "Required",
|
||||
"date": "Date",
|
||||
"username": "Username",
|
||||
"time": {
|
||||
"seconds": "seconds",
|
||||
"minutes": "minutes",
|
||||
"hours": "hours"
|
||||
},
|
||||
"add-new": "Add new"
|
||||
},
|
||||
"twitch": {
|
||||
"config": {
|
||||
"header": "Twitch module configuration",
|
||||
"enable": "Enable twitch integration",
|
||||
"apiguide-1": "You will need to create an application, here's how:",
|
||||
"apiguide-2": "Go to <1>https://dev.twitch.tv/console/apps/create</1>",
|
||||
"apiguide-3": "Use the following data for the required fields:",
|
||||
"oauth-redir-uri": "OAuth Redirect URLs",
|
||||
"apiguide-4": "Once created, create a <1>New Secret</1>, then copy both fields below!",
|
||||
"app-client-id": "App Client ID",
|
||||
"app-client-secret": "App Client Secret"
|
||||
},
|
||||
"bot": {
|
||||
"header": "Twitch bot configuration",
|
||||
"enable": "Enable twitch bot",
|
||||
"err-module-disabled": "(Twitch integration must be enabled for this!)",
|
||||
"channel-name": "Twitch channel name",
|
||||
"username": "Bot username",
|
||||
"username-expl": "must be a valid Twitch account",
|
||||
"oauth-token": "Bot OAuth token",
|
||||
"oauth-help": "You can get this by logging in with the bot account and going here: <1>https://twitchapps.com/tmi/</1>",
|
||||
"chat-keys": "Enable chat keys (for 3rd party chat integration)",
|
||||
"chat-history": "Chat history",
|
||||
"suf-messages": "messages"
|
||||
},
|
||||
"commands": {
|
||||
"command": "Command",
|
||||
"response": "Response",
|
||||
"response-help": "What does the bot reply to this command?",
|
||||
"description": "Description",
|
||||
"description-help": "What does this command do?",
|
||||
"access-level": "Access level",
|
||||
"access-everyone": "Everyone",
|
||||
"access-subscribers": "Subscribers",
|
||||
"access-vips": "VIPs",
|
||||
"access-moderators": "Moderators",
|
||||
"access-streamer": "Streamer only",
|
||||
"access-level-help": "This specifies the minimum level, eg. if you choose VIPs, moderators and streamer can still use the command",
|
||||
"header": "Bot commands",
|
||||
"new-command": "New command",
|
||||
"search": "Search by name",
|
||||
"modify-command": "Modify command"
|
||||
},
|
||||
"timers": {
|
||||
"header": "Bot timers",
|
||||
"new-timer": "New timer",
|
||||
"modify-timer": "Modify timer",
|
||||
"search": "Search by timer name",
|
||||
"messages": "Messages",
|
||||
"name-hint": "Timer name",
|
||||
"name": "Name",
|
||||
"message-help": "What to write in chat",
|
||||
"minimum-delay": "Interval",
|
||||
"minimum-delay-help": "How many time must pass between each repeat.",
|
||||
"minimum-activity": "Minimum chat activity",
|
||||
"minimum-activity-post": "messages in the last 5 minutes",
|
||||
"condition-text": "every {{time}}, at least {{messages}} messages in the last 5 minutes",
|
||||
"err-twitchbot-disabled": "Twitch bot must be enabled in order to use timers!",
|
||||
"enable": "Enable timers"
|
||||
},
|
||||
"bot-alerts": {
|
||||
"header": "Chat alerts",
|
||||
"err-twitchbot-disabled": "Twitch bot must be enabled in order to use chat alerts!",
|
||||
"err-stulbe-disabled": "Back-end integration must be enabled in order to use chat alerts!",
|
||||
"messages": "Messages",
|
||||
"follow": "Follow",
|
||||
"follow-enabled": "Enable follow notifications",
|
||||
"subscription": "Subscription",
|
||||
"sub-enabled": "Enable subscription notifications",
|
||||
"gift-sub": "Gift Sub",
|
||||
"gift-sub-enabled": "Enable gift sub notifications",
|
||||
"cheer": "Cheer",
|
||||
"cheer-enabled": "Enable cheer notifications",
|
||||
"raid": "Raid",
|
||||
"raid-enabled": "Enable raid notifications",
|
||||
"variation-header": "Variations",
|
||||
"add-variation": "Add variation",
|
||||
"variation-condition": "Condition",
|
||||
"min-months": "Subscription months (at least)",
|
||||
"delete-variation": "Delete variation"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"message": "Strimertül's database is protected by a password, please write it below to access the control panel. If the database has no password (for example, it was recently changed from having one to none), leave the field empty.",
|
||||
"header": "Authorization needed",
|
||||
"placeholder": "Password",
|
||||
"button": "Authenticate"
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
|
|
@ -1,225 +0,0 @@
|
|||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Dark theme fixes */
|
||||
body .button.is-static {
|
||||
background-color: #5d6b6b;
|
||||
}
|
||||
body .input,
|
||||
body .select select,
|
||||
body .textarea {
|
||||
background-color: #222727;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body .input::placeholder {
|
||||
color: #7e9292;
|
||||
}
|
||||
|
||||
body .input[disabled],
|
||||
body .select select[disabled],
|
||||
body .textarea[disabled],
|
||||
body .input[disabled]::placeholder {
|
||||
border-color: #5e6d6f;
|
||||
color: #464e4e;
|
||||
background-color: #6d7979;
|
||||
}
|
||||
|
||||
body .button.is-success {
|
||||
background-color: #388859;
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
padding: 1rem;
|
||||
border: 2px solid #5e6d6f;
|
||||
border-top: 0;
|
||||
border-radius: 0 0 0.4em 0.4em;
|
||||
}
|
||||
|
||||
/* Custom padding for content */
|
||||
.content-pad {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.copyblock {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Custom reward/goal classes */
|
||||
.reward-disabled,
|
||||
.goal-disabled {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.goal-reached {
|
||||
font-weight: bold;
|
||||
color: #1fdb5e;
|
||||
}
|
||||
.goal-point-percent {
|
||||
color: #879799;
|
||||
}
|
||||
|
||||
.customcommand .card-header-title code {
|
||||
background-color: transparent;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.customcommand .content blockquote {
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
/* Nice expand/contract icon without FontAwesome! */
|
||||
.icon.expand-on,
|
||||
.icon.expand-off {
|
||||
transition: all 50ms;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon.expand-on {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.icon.expand-off {
|
||||
transform: rotate(-90deg) translateY(-2px);
|
||||
}
|
||||
|
||||
/* Side menu tweaks */
|
||||
aside.menu {
|
||||
padding: 1rem 0.25rem 0;
|
||||
background-color: #272e2e;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
div.app-content {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
p.menu-label {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
aside.menu {
|
||||
position: inherit;
|
||||
overflow: inherit;
|
||||
flex: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
div.app-content {
|
||||
position: inherit;
|
||||
flex: 1;
|
||||
top: inherit;
|
||||
bottom: inherit;
|
||||
right: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.main-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fullheight fixes */
|
||||
html,
|
||||
body {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* fuck you minireset */
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
#main,
|
||||
section.main-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Labels should be non-selectable */
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Swap order of "is-active" to work with <Link> */
|
||||
.tabs.is-boxed li a.is-active {
|
||||
background-color: #1f2424;
|
||||
}
|
||||
.tabs.is-boxed li a.is-active {
|
||||
border-color: #5e6d6f;
|
||||
border-bottom-color: transparent !important;
|
||||
}
|
||||
.tabs li a.is-active {
|
||||
border-bottom-color: #1abc9c;
|
||||
color: #1abc9c;
|
||||
}
|
||||
|
||||
.field {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-card .field {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.subroute {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-active + .subroute {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
/* Sorting for tables */
|
||||
.sort-icon {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
span.sortable {
|
||||
color: #6bc8b4;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom sidebar */
|
||||
.sidebar .menu-list a.is-active {
|
||||
background-color: #5e6d6f;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
.notifications {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Inline definition lists */
|
||||
.inline-dl {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.inline-dl dt {
|
||||
white-space: nowrap;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.inline-dl dt:after {
|
||||
content: ':';
|
||||
}
|
|
@ -1,215 +1,5 @@
|
|||
import { Link, Redirect, Router, useLocation } from '@reach/router';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { RootState } from '../store';
|
||||
import { createWSClient } from '../store/api/reducer';
|
||||
import Home from './pages/Home';
|
||||
import HTTPPage from './pages/HTTP';
|
||||
import TwitchPage from './pages/twitch/Main';
|
||||
import StulbePage from './pages/stulbe/Main';
|
||||
import LoyaltyPage from './pages/loyalty/Main';
|
||||
import DebugPage from './pages/Debug';
|
||||
import LoyaltySettingPage from './pages/loyalty/Settings';
|
||||
import LoyaltyRewardsPage from './pages/loyalty/Rewards';
|
||||
import LoyaltyUserListPage from './pages/loyalty/UserList';
|
||||
import LoyaltyGoalsPage from './pages/loyalty/Goals';
|
||||
import LoyaltyRedeemQueuePage from './pages/loyalty/Queue';
|
||||
import TwitchSettingsPage from './pages/twitch/APISettings';
|
||||
import TwitchBotSettingsPage from './pages/twitch/BotSettings';
|
||||
import TwitchBotCommandsPage from './pages/twitch/Commands';
|
||||
import StulbeConfigPage from './pages/stulbe/Config';
|
||||
import StulbeWebhooksPage from './pages/stulbe/Webhook';
|
||||
import TwitchBotTimersPage from './pages/twitch/Timers';
|
||||
import { ConnectionStatus } from '../store/api/types';
|
||||
import Field from './components/Field';
|
||||
import TwitchBotAlertsPage from './pages/twitch/Alerts';
|
||||
|
||||
interface RouteItem {
|
||||
name?: string;
|
||||
route: string;
|
||||
subroutes?: RouteItem[];
|
||||
}
|
||||
|
||||
const menu: RouteItem[] = [
|
||||
{ route: '/' },
|
||||
{ route: '/http' },
|
||||
{
|
||||
route: '/twitch',
|
||||
subroutes: [
|
||||
{ route: '/twitch/settings' },
|
||||
{ route: '/twitch/bot/settings' },
|
||||
{ route: '/twitch/bot/commands' },
|
||||
{ route: '/twitch/bot/timers' },
|
||||
{ route: '/twitch/bot/alerts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
route: '/loyalty',
|
||||
subroutes: [
|
||||
{ route: '/loyalty/settings' },
|
||||
{ route: '/loyalty/users' },
|
||||
{ route: '/loyalty/queue' },
|
||||
{ route: '/loyalty/rewards' },
|
||||
{ route: '/loyalty/goals' },
|
||||
],
|
||||
},
|
||||
{
|
||||
route: '/stulbe',
|
||||
subroutes: [{ route: '/stulbe/config' }, { route: '/stulbe/webhooks' }],
|
||||
},
|
||||
];
|
||||
|
||||
function AuthModal(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [password, setPassword] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef?.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
localStorage.setItem('password', password);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal is-active">
|
||||
<div className="modal-background"></div>
|
||||
<div className="modal-card">
|
||||
<header className="modal-card-head">
|
||||
<p className="modal-card-title">{t('auth.header')}</p>
|
||||
</header>
|
||||
<section className="modal-card-body">
|
||||
<Field>{t('auth.message')}</Field>
|
||||
<Field>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={t('auth.placeholder')}
|
||||
value={password ?? ''}
|
||||
ref={inputRef}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
onKeyUp={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.code === 'Enter') {
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</section>
|
||||
<footer className="modal-card-foot">
|
||||
<button className="button is-success" onClick={() => submit()}>
|
||||
{t('auth.button')}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App(): React.ReactElement {
|
||||
const loc = useLocation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const client = useSelector((state: RootState) => state.api.client);
|
||||
const connected = useSelector(
|
||||
(state: RootState) => state.api.connectionStatus,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Create WS client
|
||||
useEffect(() => {
|
||||
if (!client) {
|
||||
dispatch(
|
||||
createWSClient({
|
||||
address:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'ws://localhost:4337/ws'
|
||||
: `ws://${loc.host}/ws`,
|
||||
password: localStorage.password,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
||||
return <AuthModal />;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
return <div className="container">{t('system.loading')}</div>;
|
||||
}
|
||||
|
||||
const basepath = '/ui/';
|
||||
|
||||
const routeItem = ({ route, name, subroutes }: RouteItem) => (
|
||||
<li key={route}>
|
||||
<Link
|
||||
getProps={({ isPartiallyCurrent, isCurrent }) => {
|
||||
const active = isCurrent || (subroutes && isPartiallyCurrent);
|
||||
return {
|
||||
className: active ? 'is-active' : '',
|
||||
};
|
||||
}}
|
||||
to={`${basepath}${route}`.replace(/\/\//gi, '/')}
|
||||
>
|
||||
{name ?? t(`pages.${route}`)}
|
||||
</Link>
|
||||
{subroutes ? (
|
||||
<ul className="subroute">{subroutes.map(routeItem)}</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="main-content columns is-fullheight">
|
||||
<section className="notifications">
|
||||
{connected !== ConnectionStatus.Connected ? (
|
||||
<div className="notification is-danger">
|
||||
{t('system.connection-lost')}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
<aside className="menu sidebar column is-3 is-fullheight section">
|
||||
<p className="menu-label is-hidden-touch">{t('system.menu-header')}</p>
|
||||
<ul className="menu-list">{menu.map(routeItem)}</ul>
|
||||
</aside>
|
||||
|
||||
<div className="app-content column is-9">
|
||||
<div className="content-pad">
|
||||
<Router basepath={basepath}>
|
||||
<Home path="/" />
|
||||
<HTTPPage path="http" />
|
||||
<TwitchPage path="twitch">
|
||||
<Redirect from="/" to="settings" noThrow />
|
||||
<TwitchSettingsPage path="settings" />
|
||||
<TwitchBotSettingsPage path="bot/settings" />
|
||||
<TwitchBotCommandsPage path="bot/commands" />
|
||||
<TwitchBotTimersPage path="bot/timers" />
|
||||
<TwitchBotAlertsPage path="bot/alerts" />
|
||||
</TwitchPage>
|
||||
<LoyaltyPage path="loyalty">
|
||||
<Redirect from="/" to="settings" noThrow />
|
||||
<LoyaltySettingPage path="settings" />
|
||||
<LoyaltyUserListPage path="users" />
|
||||
<LoyaltyRedeemQueuePage path="queue" />
|
||||
<LoyaltyRewardsPage path="rewards" />
|
||||
<LoyaltyGoalsPage path="goals" />
|
||||
</LoyaltyPage>
|
||||
<StulbePage path="stulbe">
|
||||
<Redirect from="/" to="config" noThrow />
|
||||
<StulbeConfigPage path="config" />
|
||||
<StulbeWebhooksPage path="webhooks" />
|
||||
</StulbePage>
|
||||
<DebugPage path="debug" />
|
||||
</Router>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
export default function App(): JSX.Element {
|
||||
return <main></main>;
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export interface FieldProps {
|
||||
name?: string;
|
||||
className?: string;
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
function Field({
|
||||
name,
|
||||
className,
|
||||
horizontal,
|
||||
children,
|
||||
}: React.PropsWithChildren<FieldProps>) {
|
||||
let classes = className ?? '';
|
||||
if (horizontal) {
|
||||
classes += ' is-horizontal';
|
||||
}
|
||||
let nameEl = null;
|
||||
if (name) {
|
||||
nameEl = <label className="label">{name}</label>;
|
||||
if (horizontal) {
|
||||
nameEl = <div className="field-label is-normal">{nameEl}</div>;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={`field ${classes}`}>
|
||||
{nameEl}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Field);
|
|
@ -1,85 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getInterval } from '../../lib/time-utils';
|
||||
|
||||
export interface TimeUnit {
|
||||
multiplier: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export const seconds = { multiplier: 1, unit: 'form-common.time.seconds' };
|
||||
export const minutes = { multiplier: 60, unit: 'form-common.time.minutes' };
|
||||
export const hours = { multiplier: 3600, unit: 'form-common.time.hours' };
|
||||
|
||||
export interface IntervalProps {
|
||||
active: boolean;
|
||||
value: number;
|
||||
min?: number;
|
||||
units?: TimeUnit[];
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
function Interval({ active, value, min, units, onChange }: IntervalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const timeUnits = units ?? [seconds, minutes, hours];
|
||||
|
||||
const [numInitialValue, multInitialValue] = getInterval(value);
|
||||
const [num, setNum] = useState(numInitialValue);
|
||||
const [mult, setMult] = useState(multInitialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const total = num * mult;
|
||||
if (min && total < min) {
|
||||
const [minNum, minMult] = getInterval(min);
|
||||
setNum(minNum);
|
||||
setMult(minMult);
|
||||
}
|
||||
onChange(Math.max(min ?? 0, total));
|
||||
}, [num, mult]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={num ?? ''}
|
||||
style={{ width: '6em' }}
|
||||
onChange={(ev) => {
|
||||
const intNum = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
setNum(intNum);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<span className="select">
|
||||
<select
|
||||
value={mult.toString() ?? ''}
|
||||
disabled={!active}
|
||||
onChange={(ev) => {
|
||||
const intMult = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intMult)) {
|
||||
return;
|
||||
}
|
||||
setMult(intMult);
|
||||
}}
|
||||
>
|
||||
{timeUnits.map((unit) => (
|
||||
<option key={unit.unit} value={unit.multiplier.toString()}>
|
||||
{t(unit.unit)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Interval);
|
|
@ -1,66 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface MessageArrayProps {
|
||||
placeholder?: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
function MessageArray({ value, placeholder, onChange }: MessageArrayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="control">
|
||||
{value.map((message, index) => (
|
||||
<div
|
||||
className="field has-addons"
|
||||
key={index}
|
||||
style={{ marginTop: index > 0 ? '0.5rem' : '' }}
|
||||
>
|
||||
<p className="control">
|
||||
<input
|
||||
placeholder={placeholder}
|
||||
onChange={(ev) => {
|
||||
const newMessages = [...value];
|
||||
newMessages[index] = ev.target.value;
|
||||
onChange(newMessages);
|
||||
}}
|
||||
value={message}
|
||||
className={message !== '' ? 'input' : 'input is-danger'}
|
||||
style={{ width: '28rem' }}
|
||||
/>
|
||||
</p>
|
||||
{value.length > 1 ? (
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-danger"
|
||||
onClick={() => {
|
||||
const newMessages = [...value];
|
||||
newMessages.splice(index, 1);
|
||||
onChange(newMessages.length > 0 ? newMessages : ['']);
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="field" style={{ marginTop: '0.5rem' }}>
|
||||
<p className="control">
|
||||
<button
|
||||
className="button is-success is-small"
|
||||
onClick={() => {
|
||||
onChange([...value, '']);
|
||||
}}
|
||||
>
|
||||
{t('form-common.add-new')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MessageArray);
|
|
@ -1,73 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
active: boolean;
|
||||
onClose?: () => void;
|
||||
onConfirm: () => void;
|
||||
confirmName?: string;
|
||||
confirmClass?: string;
|
||||
confirmEnabled?: boolean;
|
||||
cancelName?: string;
|
||||
cancelClass?: string;
|
||||
showCancel?: boolean;
|
||||
bgDismiss?: boolean;
|
||||
}
|
||||
|
||||
function Modal({
|
||||
active,
|
||||
title,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmName,
|
||||
confirmClass,
|
||||
confirmEnabled,
|
||||
cancelName,
|
||||
cancelClass,
|
||||
showCancel,
|
||||
bgDismiss,
|
||||
children,
|
||||
}: React.PropsWithChildren<ModalProps>): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`modal ${active ? 'is-active' : ''}`}>
|
||||
<div
|
||||
className="modal-background"
|
||||
onClick={bgDismiss ? () => onClose() : null}
|
||||
/>
|
||||
<div className="modal-card">
|
||||
<header className="modal-card-head">
|
||||
<p className="modal-card-title">{title}</p>
|
||||
{showCancel ? (
|
||||
<button
|
||||
className="delete"
|
||||
aria-label="close"
|
||||
onClick={() => onClose()}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
<section className="modal-card-body">{children}</section>
|
||||
<footer className="modal-card-foot">
|
||||
<button
|
||||
className={`button ${confirmClass ?? ''}`}
|
||||
disabled={!confirmEnabled}
|
||||
onClick={() => onConfirm()}
|
||||
>
|
||||
{confirmName ?? t('actions.ok')}
|
||||
</button>
|
||||
{showCancel ? (
|
||||
<button
|
||||
className={`button ${cancelClass ?? ''}`}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
{cancelName ?? t('actions.cancel')}
|
||||
</button>
|
||||
) : null}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Modal);
|
|
@ -1,130 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface PageListProps {
|
||||
current: number;
|
||||
max: number;
|
||||
min: number;
|
||||
itemsPerPage: number;
|
||||
onSelectChange: (itemsPerPage: number) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
function PageList({
|
||||
current,
|
||||
max,
|
||||
min,
|
||||
itemsPerPage,
|
||||
onSelectChange,
|
||||
onPageChange,
|
||||
}: PageListProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<nav
|
||||
className="pagination is-small"
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
>
|
||||
<button
|
||||
className="button pagination-previous"
|
||||
disabled={current <= min}
|
||||
onClick={() => onPageChange(current - 1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
className="button pagination-next"
|
||||
disabled={current >= max}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<select
|
||||
className="pagination-next"
|
||||
value={itemsPerPage}
|
||||
onChange={(ev) => onSelectChange(Number(ev.target.value))}
|
||||
>
|
||||
<option value={15}>15</option>
|
||||
<option value={30}>30</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<ul className="pagination-list">
|
||||
{current > min ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={t('system.pagination.gotopage', { page: min })}
|
||||
onClick={() => onPageChange(min)}
|
||||
>
|
||||
{min}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{current > min + 2 ? (
|
||||
<li>
|
||||
<span className="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{current > min + 1 ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={t('system.pagination.gotopage', {
|
||||
page: current - 1,
|
||||
})}
|
||||
onClick={() => onPageChange(current - 1)}
|
||||
>
|
||||
{current - 1}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
<button
|
||||
className="pagination-link is-current"
|
||||
aria-label={t('system.pagination.page', {
|
||||
page: current,
|
||||
})}
|
||||
aria-current="page"
|
||||
>
|
||||
{current}
|
||||
</button>
|
||||
</li>
|
||||
{current < max ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={t('system.pagination.gotopage', {
|
||||
page: current + 1,
|
||||
})}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
>
|
||||
{current + 1}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{current < max - 2 ? (
|
||||
<li>
|
||||
<span className="pagination-ellipsis">…</span>
|
||||
</li>
|
||||
) : null}
|
||||
{current < max - 1 ? (
|
||||
<li>
|
||||
<button
|
||||
className="button pagination-link"
|
||||
aria-label={t('system.pagination.gotopage', {
|
||||
page: max,
|
||||
})}
|
||||
onClick={() => onPageChange(max)}
|
||||
>
|
||||
{max}
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(PageList);
|
|
@ -1,52 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
|
||||
function TabbedView({
|
||||
children,
|
||||
}: // eslint-disable-next-line @typescript-eslint/ban-types
|
||||
React.PropsWithChildren<{}>): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = React.useState(null);
|
||||
|
||||
const tabs = React.Children.map(children, (elem, i) => {
|
||||
const id =
|
||||
(typeof elem === 'object' && 'props' in elem
|
||||
? elem.props['data-name']
|
||||
: null) ?? `TAB#${i}`;
|
||||
return {
|
||||
id,
|
||||
tabContent: elem,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === null) {
|
||||
setActiveTab(tabs[0].id);
|
||||
}
|
||||
}, [children, activeTab]);
|
||||
|
||||
if (activeTab === null) {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
const active = tabs.find((elem) => elem.id === activeTab);
|
||||
return (
|
||||
<>
|
||||
<div className="tabs is-boxed" style={{ marginBottom: 0 }}>
|
||||
<ul>
|
||||
{tabs.map((t) => (
|
||||
<li key={t.id}>
|
||||
<a
|
||||
className={activeTab === t.id ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
>
|
||||
{t.id}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="tabContent">{active.tabContent}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TabbedView);
|
|
@ -1,124 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../../store';
|
||||
|
||||
export default function DebugPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const api = useSelector((state: RootState) => state.api.client);
|
||||
const [readKey, setReadKey] = useState('');
|
||||
const [readValue, setReadValue] = useState('');
|
||||
const [writeKey, setWriteKey] = useState('');
|
||||
const [writeValue, setWriteValue] = useState('');
|
||||
const [writeErrorMsg, setWriteErrorMsg] = useState(null);
|
||||
|
||||
const performRead = async () => {
|
||||
const value = await api.getKey(readKey);
|
||||
setReadValue(value);
|
||||
};
|
||||
const performWrite = async () => {
|
||||
const result = await api.putKey(writeKey, writeValue);
|
||||
console.log(result);
|
||||
};
|
||||
const fixJSON = () => {
|
||||
try {
|
||||
setWriteValue(JSON.stringify(JSON.parse(writeValue)));
|
||||
setWriteErrorMsg(null);
|
||||
} catch (e) {
|
||||
setWriteErrorMsg(e.message);
|
||||
}
|
||||
};
|
||||
const dumpKeys = async () => {
|
||||
console.log(await api.keyList());
|
||||
};
|
||||
const dumpAll = async () => {
|
||||
console.log(await api.getKeysByPrefix(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="title is-3" style={{ color: '#fa3' }}>
|
||||
{t('debug.friendly-greeting')}
|
||||
</p>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<label className="label">{t('debug.console-header')}</label>
|
||||
<button className="button" onClick={dumpKeys}>
|
||||
{t('debug.get-keys')}
|
||||
</button>
|
||||
<button className="button" onClick={dumpAll}>
|
||||
{t('debug.get-all')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<label className="label">{t('debug.read-key')}</label>
|
||||
<div className="field has-addons">
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={readKey}
|
||||
onChange={(ev) => setReadKey(ev.target.value)}
|
||||
placeholder="some-bucket/some-key"
|
||||
/>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button className="button is-primary" onClick={performRead}>
|
||||
{t('debug.read-btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<textarea className="textarea" value={readValue} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="column">
|
||||
<label className="label">{t('debug.write-key')}</label>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
value={writeKey}
|
||||
onChange={(ev) => setWriteKey(ev.target.value)}
|
||||
placeholder="some-bucket/some-key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
value={writeValue}
|
||||
onChange={(ev) => setWriteValue(ev.target.value)}
|
||||
/>
|
||||
{writeErrorMsg ? (
|
||||
<p>
|
||||
<code>{writeErrorMsg}</code>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<button className="button is-primary" onClick={performWrite}>
|
||||
{t('debug.write-btn')}
|
||||
</button>{' '}
|
||||
<button className="button" onClick={fixJSON}>
|
||||
{t('debug.fix-json-btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../store/api/reducer';
|
||||
import Field from '../components/Field';
|
||||
|
||||
export default function HTTPPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [httpConfig, setHTTPConfig] = useModule(modules.httpConfig);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = httpConfig === null;
|
||||
const active = httpConfig?.enable_static_server ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('http.header')}</h1>
|
||||
<Field name={t('http.server-bind')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={busy}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder=":8080"
|
||||
value={httpConfig?.bind ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.httpConfigChanged({
|
||||
...httpConfig,
|
||||
bind: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field name={t('http.kv-password')}>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
disabled={busy}
|
||||
placeholder="None"
|
||||
value={httpConfig?.kv_password ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.httpConfigChanged({
|
||||
...httpConfig,
|
||||
kv_password: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
<p className="help">Leave empty to disable authentication</p>
|
||||
</Field>
|
||||
<Field name={t('http.static-content')}>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy}
|
||||
checked={active}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.httpConfigChanged({
|
||||
...httpConfig,
|
||||
enable_static_server: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('http.enable-static')}
|
||||
</label>
|
||||
</Field>
|
||||
{active && (
|
||||
<Field name={t('http.static-root-path')}>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
disabled={busy || !active}
|
||||
value={httpConfig?.path ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.httpConfigChanged({
|
||||
...httpConfig,
|
||||
path: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setHTTPConfig(httpConfig));
|
||||
const port = httpConfig.bind.split(':', 2)[1] ?? '4337';
|
||||
if (port !== window.location.port) {
|
||||
window.location.port = port;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function Home(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
return <div>Work in progress!!</div>;
|
||||
}
|
|
@ -1,384 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { RootState } from '../../../store';
|
||||
import { modules } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
import { LoyaltyGoal } from '../../../store/api/types';
|
||||
import Field from '../../components/Field';
|
||||
|
||||
interface GoalItemProps {
|
||||
item: LoyaltyGoal;
|
||||
onToggleState: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
function GoalItem({ item, onToggleState, onEdit, onDelete }: GoalItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ??
|
||||
t('loyalty.points-fallback'),
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const placeholder = 'https://bulma.io/images/placeholders/128x128.png';
|
||||
const contributors = Object.entries(item.contributors ?? {}).sort(
|
||||
([, pointsA], [, pointsB]) => pointsB - pointsA,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
<div className="media-left">
|
||||
<figure className="image is-32x32">
|
||||
<img src={item.image || placeholder} alt="Icon" />
|
||||
</figure>
|
||||
</div>
|
||||
{item.enabled ? (
|
||||
item.name
|
||||
) : (
|
||||
<span className="goal-disabled">{item.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.contributed >= item.total ? (
|
||||
<span className="goal-reached">{t('loyalty.goals.reached')}</span>
|
||||
) : (
|
||||
<>
|
||||
{item.contributed} / {item.total} {currency}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
{item.description}
|
||||
<div className="contributors" style={{ marginTop: '1rem' }}>
|
||||
{contributors.length > 0 ? (
|
||||
<>
|
||||
<b>{t('loyalty.goals.contributors')}</b>
|
||||
<table className="table is-striped is-narrow">
|
||||
<tr>
|
||||
<th>{t('form-common.username')}</th>
|
||||
<th>{t('loyalty.points')}</th>
|
||||
</tr>
|
||||
{contributors.map(([user, points]) => (
|
||||
<tr>
|
||||
<td>{user}</td>
|
||||
<td>
|
||||
{points}{' '}
|
||||
<span className="goal-point-percent">
|
||||
({Math.round((points / item.total) * 10000) / 100}%)
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<b>{t('loyalty.goals.no-contributors')}</b>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? t('actions.disable') : t('actions.enable')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
{t('actions.edit')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
{t('actions.delete')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GoalModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (r: LoyaltyGoal) => void;
|
||||
onClose: () => void;
|
||||
initialData?: LoyaltyGoal;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function GoalModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: GoalModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
const [goals] = useModule(modules.loyaltyGoals);
|
||||
|
||||
const [id, setID] = useState(initialData?.id ?? '');
|
||||
const [name, setName] = useState(initialData?.name ?? '');
|
||||
const [image, setImage] = useState(initialData?.image ?? '');
|
||||
const [description, setDescription] = useState(
|
||||
initialData?.description ?? '',
|
||||
);
|
||||
const [total, setTotal] = useState(initialData?.total ?? 0);
|
||||
|
||||
const setIDex = (newID) =>
|
||||
setID(newID.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-'));
|
||||
|
||||
const slug = id || name?.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-') || '';
|
||||
const idExists = goals?.some((goal) => goal.id === slug) ?? false;
|
||||
const idInvalid = slug !== initialData?.id && idExists;
|
||||
|
||||
const validForm = idInvalid === false && name !== '' && total >= 0;
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm({
|
||||
id: slug,
|
||||
name,
|
||||
description,
|
||||
total,
|
||||
enabled: initialData?.enabled ?? false,
|
||||
image,
|
||||
contributed: initialData?.contributed ?? 0,
|
||||
contributors: initialData?.contributors ?? {},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<Field name={t('loyalty.goals.id')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className={idInvalid ? 'input is-danger' : 'input'}
|
||||
type="text"
|
||||
placeholder={t('loyalty.goals.id-placeholder')}
|
||||
value={slug}
|
||||
onChange={(ev) => setIDex(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
{idInvalid ? (
|
||||
<p className="help is-danger">
|
||||
{t('loyalty.goals.err-goalid-dup')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="help">{t('loyalty.goals.id-help')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.goals.name')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.goals.name-placeholder')}
|
||||
value={name ?? ''}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.goals.icon')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.goals.icon-placeholder')}
|
||||
value={image ?? ''}
|
||||
onChange={(ev) => setImage(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.goals.description')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder={t('loyalty.goals.description-placeholder')}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
value={description}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('form-common.required')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={total ?? ''}
|
||||
onChange={(ev) => setTotal(parseInt(ev.target.value, 10))}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">{loyaltyConfig?.currency}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoyaltyGoalsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [goals, setGoals] = useModule(modules.loyaltyGoals);
|
||||
const [twitchConfig] = useModule(modules.twitchConfig);
|
||||
const [loyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const twitchActive = twitchConfig?.enabled ?? false;
|
||||
const loyaltyEnabled = loyaltyConfig?.enabled ?? false;
|
||||
const active = twitchActive && loyaltyEnabled;
|
||||
|
||||
const [goalFilter, setGoalFilter] = useState('');
|
||||
const goalFilterLC = goalFilter.toLowerCase();
|
||||
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [showModifyGoal, setShowModifyGoal] = useState(null);
|
||||
|
||||
const createGoal = (newGoal: LoyaltyGoal) => {
|
||||
dispatch(setGoals([...(goals ?? []), newGoal]));
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
const toggleGoal = (goalID: string) => {
|
||||
dispatch(
|
||||
setGoals(
|
||||
goals.map((entry) =>
|
||||
entry.id === goalID
|
||||
? {
|
||||
...entry,
|
||||
enabled: !entry.enabled,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const modifyGoal = (originalGoalID: string, goal: LoyaltyGoal) => {
|
||||
dispatch(
|
||||
setGoals(
|
||||
goals.map((entry) => (entry.id === originalGoalID ? goal : entry)),
|
||||
),
|
||||
);
|
||||
setShowModifyGoal(null);
|
||||
};
|
||||
|
||||
const deleteGoal = (goalID: string) => {
|
||||
dispatch(setGoals(goals.filter((entry) => entry.id !== goalID)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('loyalty.goals.header')}</h1>
|
||||
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button
|
||||
className="button"
|
||||
disabled={!active}
|
||||
onClick={() => setCreateModal(true)}
|
||||
>
|
||||
{t('loyalty.goals.new')}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.goals.search')}
|
||||
value={goalFilter}
|
||||
onChange={(ev) => setGoalFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<GoalModal
|
||||
title={t('loyalty.goals.new')}
|
||||
confirmText={t('actions.create')}
|
||||
active={createModal}
|
||||
onConfirm={createGoal}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyGoal ? (
|
||||
<GoalModal
|
||||
title={t('loyalty.goals.modify')}
|
||||
confirmText={t('actions.edit')}
|
||||
active={true}
|
||||
onConfirm={(goal) => modifyGoal(showModifyGoal.id, goal)}
|
||||
initialData={showModifyGoal}
|
||||
onClose={() => setShowModifyGoal(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="goal-list" style={{ marginTop: '1rem' }}>
|
||||
{goals
|
||||
?.filter((goal) => goal.name.toLowerCase().includes(goalFilterLC))
|
||||
.map((goal) => (
|
||||
<GoalItem
|
||||
key={goal.name}
|
||||
item={goal}
|
||||
onDelete={() => deleteGoal(goal.id)}
|
||||
onEdit={() => setShowModifyGoal(goal)}
|
||||
onToggleState={() => toggleGoal(goal.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function LoyaltyPage({
|
||||
children,
|
||||
}: RouteComponentProps<React.PropsWithChildren<unknown>>): React.ReactElement {
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -1,203 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule, useUserPoints } from '../../../lib/react-utils';
|
||||
import {
|
||||
modules,
|
||||
removeRedeem,
|
||||
setUserPoints,
|
||||
} from '../../../store/api/reducer';
|
||||
import PageList from '../../components/PageList';
|
||||
import { LoyaltyRedeem } from '../../../store/api/types';
|
||||
|
||||
interface SortingOrder {
|
||||
key: 'user' | 'when';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function LoyaltyRedeemQueuePage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [redemptions] = useModule(modules.loyaltyRedeemQueue);
|
||||
|
||||
// Big hack but this is required or refunds break
|
||||
useUserPoints();
|
||||
|
||||
const [sorting, setSorting] = useState<SortingOrder>({
|
||||
key: 'when',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
||||
const [page, setPage] = useState(0);
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const changeSort = (key: 'user' | 'when') => {
|
||||
if (sorting.key === key) {
|
||||
// Same key, swap sorting order
|
||||
setSorting({
|
||||
...sorting,
|
||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
} else {
|
||||
// Different key, change to sort that key
|
||||
setSorting({ ...sorting, key, order: 'asc' });
|
||||
}
|
||||
};
|
||||
|
||||
const filtered =
|
||||
redemptions?.filter(({ username }) => username.includes(usernameFilter)) ??
|
||||
[];
|
||||
|
||||
const sortedEntries = filtered;
|
||||
switch (sorting.key) {
|
||||
case 'user':
|
||||
if (sorting.order === 'asc') {
|
||||
sortedEntries.sort((a, b) => (a.username > b.username ? 1 : -1));
|
||||
} else {
|
||||
sortedEntries.sort((a, b) => (a.username < b.username ? 1 : -1));
|
||||
}
|
||||
break;
|
||||
case 'when':
|
||||
if (sorting.order === 'asc') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(
|
||||
(a, b) =>
|
||||
Date.parse(a.when.toString()) - Date.parse(b.when.toString()),
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.when.toString()) - Date.parse(a.when.toString()),
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// unreacheable
|
||||
}
|
||||
|
||||
const paged = sortedEntries.slice(
|
||||
page * entriesPerPage,
|
||||
(page + 1) * entriesPerPage,
|
||||
);
|
||||
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
|
||||
|
||||
const acceptRedeem = (redeem: LoyaltyRedeem) => {
|
||||
// Just take the redeem off the list
|
||||
dispatch(removeRedeem(redeem));
|
||||
};
|
||||
|
||||
const refundRedeem = (redeem: LoyaltyRedeem) => {
|
||||
// Give points back to the viewer
|
||||
dispatch(
|
||||
setUserPoints({
|
||||
user: redeem.username,
|
||||
points: redeem.reward.price,
|
||||
relative: true,
|
||||
}),
|
||||
);
|
||||
// Take the redeem off the list
|
||||
dispatch(removeRedeem(redeem));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('loyalty.queue.header')}</h1>
|
||||
{redemptions ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<input
|
||||
className="input is-small"
|
||||
type="text"
|
||||
placeholder={t('loyalty.queue.search')}
|
||||
value={usernameFilter}
|
||||
onChange={(ev) =>
|
||||
setUsernameFilter(ev.target.value.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
<table className="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '20%' }}>
|
||||
<span className="sortable" onClick={() => changeSort('when')}>
|
||||
{t('form-common.date')}
|
||||
{sorting.key === 'when' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sortable" onClick={() => changeSort('user')}>
|
||||
{t('form-common.username')}
|
||||
{sorting.key === 'user' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th>{t('loyalty.queue.reward-name')}</th>
|
||||
<th>{t('loyalty.queue.request')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{paged.map((redemption) => (
|
||||
<tr
|
||||
key={`${redemption.when}-${redemption.username}-${redemption.reward.id}`}
|
||||
>
|
||||
<td>{new Date(redemption.when).toLocaleString()}</td>
|
||||
<td>
|
||||
{redemption.display_name} ({redemption.username})
|
||||
</td>
|
||||
<td>{redemption.reward.name}</td>
|
||||
<td>{redemption.request_text}</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<a onClick={() => acceptRedeem(redemption)}>
|
||||
{t('loyalty.queue.accept')}
|
||||
</a>
|
||||
{redemption.username !== '@PLATFORM' ? (
|
||||
<>
|
||||
{' 🞄 '}
|
||||
<a onClick={() => refundRedeem(redemption)}>
|
||||
{t('loyalty.queue.refund')}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>{t('loyalty.queue.err-not-available')}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,440 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import prettyTime from 'pretty-ms';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { RootState } from '../../../store';
|
||||
import { createRedeem, modules } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
import { LoyaltyReward } from '../../../store/api/types';
|
||||
import Field from '../../components/Field';
|
||||
import Interval from '../../components/Interval';
|
||||
|
||||
interface RewardItemProps {
|
||||
item: LoyaltyReward;
|
||||
onToggleState: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
}
|
||||
function RewardItem({
|
||||
item,
|
||||
onToggleState,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onTest,
|
||||
}: RewardItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ??
|
||||
t('loyalty.points-fallback'),
|
||||
);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const placeholder = 'https://bulma.io/images/placeholders/128x128.png';
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
<div className="media-left">
|
||||
<figure className="image is-32x32">
|
||||
<img src={item.image || placeholder} alt="Icon" />
|
||||
</figure>
|
||||
</div>
|
||||
{item.enabled ? (
|
||||
item.name
|
||||
) : (
|
||||
<span className="reward-disabled">{item.name}</span>
|
||||
)}
|
||||
<code style={{ backgroundColor: 'transparent', color: 'inherit' }}>
|
||||
(<span style={{ color: '#1abc9c' }}>{item.id}</span>)
|
||||
</code>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{item.price} {currency}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
{item.description}
|
||||
{item.cooldown > 0 ? (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<b>{t('loyalty.rewards.cooldown')}:</b>{' '}
|
||||
{prettyTime(item.cooldown * 1000)}
|
||||
</div>
|
||||
) : null}
|
||||
{item.required_info ? (
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<b>{t('loyalty.rewards.required-info')}:</b> {item.required_info}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onTest}>
|
||||
{t('actions.test')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? t('actions.disable') : t('actions.enable')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
{t('actions.edit')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
{t('actions.delete')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RewardModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (r: LoyaltyReward) => void;
|
||||
onClose: () => void;
|
||||
initialData?: LoyaltyReward;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function RewardModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: RewardModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ?? 'points',
|
||||
);
|
||||
const [rewards] = useModule(modules.loyaltyRewards);
|
||||
|
||||
const [id, setID] = useState(initialData?.id ?? '');
|
||||
const [name, setName] = useState(initialData?.name ?? '');
|
||||
const [image, setImage] = useState(initialData?.image ?? '');
|
||||
const [description, setDescription] = useState(
|
||||
initialData?.description ?? '',
|
||||
);
|
||||
const [price, setPrice] = useState(initialData?.price ?? 0);
|
||||
const [extraDetails, setExtraDetails] = useState(
|
||||
initialData?.required_info ?? '',
|
||||
);
|
||||
const [extraRequired, setExtraRequired] = useState(extraDetails !== '');
|
||||
|
||||
const [cooldown, setCooldown] = useState(initialData?.cooldown ?? 0);
|
||||
|
||||
const setIDex = (newID) =>
|
||||
setID(newID.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-'));
|
||||
|
||||
const slug = id || name?.toLowerCase().replace(/[^a-zA-Z0-9]/gi, '-') || '';
|
||||
const idExists = rewards?.some((reward) => reward.id === slug) ?? false;
|
||||
const idInvalid = slug !== initialData?.id && idExists;
|
||||
|
||||
const validForm = idInvalid === false && name !== '' && price >= 0;
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm({
|
||||
id: slug,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
enabled: initialData?.enabled ?? false,
|
||||
image,
|
||||
required_info: extraRequired ? extraDetails : undefined,
|
||||
cooldown,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<Field name={t('loyalty.rewards.id')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className={idInvalid ? 'input is-danger' : 'input'}
|
||||
type="text"
|
||||
placeholder={t('loyalty.rewards.id-placeholder')}
|
||||
value={slug}
|
||||
onChange={(ev) => setIDex(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
{idInvalid ? (
|
||||
<p className="help is-danger">
|
||||
{t('loyalty.rewards.err-rewid-dup')}
|
||||
</p>
|
||||
) : (
|
||||
<p className="help">{t('loyalty.rewards.id-help')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.rewards.name')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.rewards.name-placeholder')}
|
||||
value={name ?? ''}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.rewards.icon')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.rewards.icon-placeholder')}
|
||||
value={image ?? ''}
|
||||
onChange={(ev) => setImage(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.rewards.description')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder={t('loyalty.rewards.description-placeholder')}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
value={description}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.rewards.cost')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={price ?? ''}
|
||||
onChange={(ev) => setPrice(parseInt(ev.target.value, 10))}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">{currency}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field horizontal>
|
||||
<div className="field-label is-normal" />
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={extraRequired}
|
||||
onChange={(ev) => setExtraRequired(ev.target.checked)}
|
||||
/>{' '}
|
||||
{t('loyalty.rewards.requires-extra-info')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
{extraRequired ? (
|
||||
<>
|
||||
<Field name={t('loyalty.rewards.extra-info')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.rewards.extra-info-placeholder')}
|
||||
value={extraDetails ?? ''}
|
||||
onChange={(ev) => setExtraDetails(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
<Field horizontal name={t('loyalty.rewards.cooldown')}>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<Interval active={active} value={cooldown} onChange={setCooldown} />
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoyaltyRewardsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [rewards, setRewards] = useModule(modules.loyaltyRewards);
|
||||
const [twitchConfig] = useModule(modules.twitchConfig);
|
||||
const [loyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const twitchActive = twitchConfig?.enabled ?? false;
|
||||
const loyaltyEnabled = loyaltyConfig?.enabled ?? false;
|
||||
const active = twitchActive && loyaltyEnabled;
|
||||
|
||||
const [rewardFilter, setRewardFilter] = useState('');
|
||||
const rewardFilterLC = rewardFilter.toLowerCase();
|
||||
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [showModifyReward, setShowModifyReward] = useState(null);
|
||||
|
||||
const createReward = (newReward: LoyaltyReward) => {
|
||||
dispatch(setRewards([...(rewards ?? []), newReward]));
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
const toggleReward = (rewardID: string) => {
|
||||
dispatch(
|
||||
setRewards(
|
||||
rewards.map((entry) =>
|
||||
entry.id === rewardID
|
||||
? {
|
||||
...entry,
|
||||
enabled: !entry.enabled,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const modifyReward = (originRewardID: string, reward: LoyaltyReward) => {
|
||||
dispatch(
|
||||
setRewards(
|
||||
rewards.map((entry) => (entry.id === originRewardID ? reward : entry)),
|
||||
),
|
||||
);
|
||||
setShowModifyReward(null);
|
||||
};
|
||||
|
||||
const deleteReward = (rewardID: string) => {
|
||||
dispatch(setRewards(rewards.filter((entry) => entry.id !== rewardID)));
|
||||
};
|
||||
|
||||
const testRedeem = (reward: LoyaltyReward) => {
|
||||
dispatch(
|
||||
createRedeem({
|
||||
username: '@PLATFORM',
|
||||
display_name: 'me :3',
|
||||
when: new Date(),
|
||||
reward,
|
||||
request_text: '',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('loyalty.rewards.header')}</h1>
|
||||
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button
|
||||
className="button"
|
||||
disabled={!active}
|
||||
onClick={() => setCreateModal(true)}
|
||||
>
|
||||
{t('loyalty.rewards.new-reward')}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.rewards.search')}
|
||||
value={rewardFilter}
|
||||
onChange={(ev) => setRewardFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RewardModal
|
||||
title={t('loyalty.rewards.new-reward')}
|
||||
confirmText={t('actions.create')}
|
||||
active={createModal}
|
||||
onConfirm={createReward}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyReward ? (
|
||||
<RewardModal
|
||||
title={t('loyalty.rewards.modify-reward')}
|
||||
confirmText={t('actions.edit')}
|
||||
active={true}
|
||||
onConfirm={(reward) => modifyReward(showModifyReward.id, reward)}
|
||||
initialData={showModifyReward}
|
||||
onClose={() => setShowModifyReward(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="reward-list" style={{ marginTop: '1rem' }}>
|
||||
{rewards
|
||||
?.filter((reward) =>
|
||||
reward.name.toLowerCase().includes(rewardFilterLC),
|
||||
)
|
||||
.map((reward) => (
|
||||
<RewardItem
|
||||
key={reward.id}
|
||||
item={reward}
|
||||
onDelete={() => deleteReward(reward.id)}
|
||||
onEdit={() => setShowModifyReward(reward)}
|
||||
onToggleState={() => toggleReward(reward.id)}
|
||||
onTest={() => testRedeem(reward)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,168 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
import Field from '../../components/Field';
|
||||
import Interval from '../../components/Interval';
|
||||
|
||||
export default function LoyaltySettingPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [loyaltyConfig, setLoyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
const [twitchConfig] = useModule(modules.twitchConfig);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const twitchActive = twitchConfig?.enabled ?? false;
|
||||
const twitchBotActive = twitchConfig?.enable_bot ?? false;
|
||||
const loyaltyEnabled = loyaltyConfig?.enabled ?? false;
|
||||
const active = twitchActive && twitchBotActive && loyaltyEnabled;
|
||||
|
||||
const [interval, setInterval] = useState(
|
||||
loyaltyConfig?.points?.interval ?? 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
points: {
|
||||
...loyaltyConfig?.points,
|
||||
interval,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [interval]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('loyalty.config.header')}</h1>
|
||||
<Field>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!twitchActive || !twitchBotActive}
|
||||
checked={active}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
enabled: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{` ${t('loyalty.config.enable')} `}
|
||||
{twitchActive && twitchBotActive
|
||||
? ''
|
||||
: t('loyalty.config.err-twitchbot-disabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('loyalty.config.currency-name')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('loyalty.points-fallback')}
|
||||
value={loyaltyConfig?.currency ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
currency: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field
|
||||
name={t('loyalty.config.point-reward-frequency', {
|
||||
points: loyaltyConfig?.currency || t('loyalty.points-fallback'),
|
||||
})}
|
||||
>
|
||||
<div className="field has-addons" style={{ marginBottom: 0 }}>
|
||||
<p className="control">
|
||||
<a className="button is-static">
|
||||
{t('loyalty.config.give-points')}
|
||||
</a>
|
||||
</p>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={loyaltyConfig?.points?.amount ?? ''}
|
||||
onChange={(ev) => {
|
||||
const amount = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(amount)) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
points: {
|
||||
...loyaltyConfig?.points,
|
||||
amount,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">
|
||||
{t('loyalty.config.points-every')}
|
||||
</a>
|
||||
</p>
|
||||
<Interval
|
||||
value={interval}
|
||||
onChange={setInterval}
|
||||
active={active}
|
||||
min={5}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('loyalty.config.bonus-points')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={loyaltyConfig?.points?.activity_bonus ?? ''}
|
||||
onChange={(ev) => {
|
||||
const bonus = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(bonus)) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
apiReducer.actions.loyaltyConfigChanged({
|
||||
...loyaltyConfig,
|
||||
points: {
|
||||
...loyaltyConfig?.points,
|
||||
activity_bonus: bonus,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setLoyaltyConfig(loyaltyConfig));
|
||||
}}
|
||||
>
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,303 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageList from '../../components/PageList';
|
||||
import { useModule, useUserPoints } from '../../../lib/react-utils';
|
||||
import { RootState } from '../../../store';
|
||||
import { modules, setUserPoints } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
import { LoyaltyPointsEntry } from '../../../store/api/types';
|
||||
import Field from '../../components/Field';
|
||||
|
||||
interface UserData {
|
||||
user: string;
|
||||
entry: LoyaltyPointsEntry;
|
||||
}
|
||||
|
||||
interface UserModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (r: UserData) => void;
|
||||
onClose: () => void;
|
||||
initialData?: UserData;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function UserModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: UserModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const currency = useSelector(
|
||||
(state: RootState) =>
|
||||
state.api.moduleConfigs?.loyaltyConfig?.currency ?? 'points',
|
||||
);
|
||||
|
||||
const [user, setUser] = useState(initialData.user);
|
||||
const [entry, setEntry] = useState(initialData.entry);
|
||||
const userEditable = initialData.user === '';
|
||||
|
||||
const nameValid = user !== '';
|
||||
const pointsValid = Number.isFinite(entry.points);
|
||||
const validForm = nameValid && pointsValid;
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm({
|
||||
user,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<Field name={t('form-common.username')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active || !userEditable}
|
||||
className={!nameValid ? 'input is-danger' : 'input'}
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={user ?? ''}
|
||||
onChange={(ev) => setUser(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field horizontal>
|
||||
<div className="field-label is-normal">
|
||||
<label className="label" style={{ textTransform: 'capitalize' }}>
|
||||
{currency}
|
||||
</label>
|
||||
</div>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
value={entry.points ?? ''}
|
||||
onChange={(ev) =>
|
||||
setEntry({ ...entry, points: parseInt(ev.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortingOrder {
|
||||
key: 'user' | 'points';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
export default function LoyaltyUserListPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loyaltyConfig] = useModule(modules.loyaltyConfig);
|
||||
const currency = loyaltyConfig?.currency ?? t('loyalty.points-fallback');
|
||||
const users = useUserPoints();
|
||||
const dispatch = useDispatch();
|
||||
const [sorting, setSorting] = useState<SortingOrder>({
|
||||
key: 'points',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const [entriesPerPage, setEntriesPerPage] = useState(15);
|
||||
const [page, setPage] = useState(0);
|
||||
const [usernameFilter, setUsernameFilter] = useState('');
|
||||
const [editModal, setEditModal] = useState<UserData>(null);
|
||||
const [createModal, setCreateModal] = useState<boolean>(false);
|
||||
|
||||
const changeSort = (key: 'user' | 'points') => {
|
||||
if (sorting.key === key) {
|
||||
// Same key, swap sorting order
|
||||
setSorting({
|
||||
...sorting,
|
||||
order: sorting.order === 'asc' ? 'desc' : 'asc',
|
||||
});
|
||||
} else {
|
||||
// Different key, change to sort that key
|
||||
setSorting({ ...sorting, key, order: 'asc' });
|
||||
}
|
||||
};
|
||||
|
||||
const rawEntries = Object.entries(users ?? []);
|
||||
const filtered = rawEntries.filter(([user]) => user.includes(usernameFilter));
|
||||
|
||||
const sortedEntries = filtered;
|
||||
switch (sorting.key) {
|
||||
case 'user':
|
||||
if (sorting.order === 'asc') {
|
||||
sortedEntries.sort(([userA], [userB]) => (userA > userB ? 1 : -1));
|
||||
} else {
|
||||
sortedEntries.sort(([userA], [userB]) => (userA < userB ? 1 : -1));
|
||||
}
|
||||
break;
|
||||
case 'points':
|
||||
if (sorting.order === 'asc') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(([_a, a], [_b, b]) => a.points - b.points);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
sortedEntries.sort(([_a, a], [_b, b]) => b.points - a.points);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// unreacheable
|
||||
}
|
||||
|
||||
const offset = page * entriesPerPage;
|
||||
const paged = sortedEntries.slice(offset, offset + entriesPerPage);
|
||||
const totalPages = Math.floor(sortedEntries.length / entriesPerPage);
|
||||
|
||||
const modifyUser = ({ entry, user }: UserData) => {
|
||||
dispatch(setUserPoints({ user, points: entry.points, relative: false }));
|
||||
setEditModal(null);
|
||||
};
|
||||
const assignPoints = ({ entry, user }: UserData) => {
|
||||
console.log(user, entry);
|
||||
dispatch(setUserPoints({ user, points: entry.points, relative: true }));
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserModal
|
||||
title={t('loyalty.userlist.give-points')}
|
||||
confirmText={t('loyalty.userlist.give-button')}
|
||||
active={createModal}
|
||||
onConfirm={(entry) => assignPoints(entry)}
|
||||
initialData={{ user: '', entry: { points: 0 } }}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{editModal ? (
|
||||
<UserModal
|
||||
title={t('loyalty.userlist.modify-balance')}
|
||||
confirmText={t('actions.edit')}
|
||||
active={true}
|
||||
onConfirm={(entry) => modifyUser(entry)}
|
||||
initialData={editModal}
|
||||
onClose={() => setEditModal(null)}
|
||||
/>
|
||||
) : null}
|
||||
<h1 className="title is-4">
|
||||
{t('loyalty.userlist.userlist-header', { currency })}
|
||||
</h1>
|
||||
{users ? (
|
||||
<>
|
||||
<div className="field">
|
||||
<input
|
||||
className="input is-small"
|
||||
type="text"
|
||||
placeholder={t('loyalty.queue.search')}
|
||||
value={usernameFilter}
|
||||
onChange={(ev) =>
|
||||
setUsernameFilter(ev.target.value.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<a className="button is-small" onClick={() => setCreateModal(true)}>
|
||||
{t('loyalty.userlist.give-points')}
|
||||
</a>
|
||||
</div>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
<table className="table is-striped is-fullwidth">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span className="sortable" onClick={() => changeSort('user')}>
|
||||
{t('form-common.username')}
|
||||
{sorting.key === 'user' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th style={{ width: '20%' }}>
|
||||
<span
|
||||
className="sortable"
|
||||
onClick={() => changeSort('points')}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{currency}
|
||||
{sorting.key === 'points' ? (
|
||||
<span className="sort-icon">
|
||||
{sorting.order === 'asc' ? '▴' : '▾'}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</th>
|
||||
<th style={{ width: '10%' }} />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{paged.map(([user, p]) => (
|
||||
<tr key={user}>
|
||||
<td>{user}</td>
|
||||
<td>{p.points}</td>
|
||||
<td style={{ textAlign: 'right', paddingRight: '1rem' }}>
|
||||
<a
|
||||
onClick={() =>
|
||||
setEditModal({
|
||||
user,
|
||||
entry: p,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('actions.edit')}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<PageList
|
||||
current={page + 1}
|
||||
min={1}
|
||||
max={totalPages + 1}
|
||||
itemsPerPage={entriesPerPage}
|
||||
onSelectChange={(em) => setEntriesPerPage(em)}
|
||||
onPageChange={(p) => setPage(p - 1)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p>{t('loyalty.userlist.err-not-available')}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import Stulbe from '../../../lib/stulbe-lib';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
import Field from '../../components/Field';
|
||||
|
||||
export default function StulbeConfigPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [stulbeConfig, setStulbeConfig] = useModule(modules.stulbeConfig);
|
||||
const [testResult, setTestResult] = useState<string>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = stulbeConfig === null;
|
||||
const active = stulbeConfig?.enabled ?? false;
|
||||
|
||||
const test = async () => {
|
||||
try {
|
||||
const client = new Stulbe(stulbeConfig.endpoint);
|
||||
await client.auth(stulbeConfig.username, stulbeConfig.auth_key);
|
||||
setTestResult(t('backend.config.auth-success-message'));
|
||||
} catch (e) {
|
||||
setTestResult(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('backend.config.header')}</h1>
|
||||
<Field>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
disabled={busy}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.stulbeConfigChanged({
|
||||
...stulbeConfig,
|
||||
enabled: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('backend.config.enable')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('backend.config.endpoint')}>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="https://stulbe.ovo.ovh"
|
||||
disabled={busy || !active}
|
||||
value={stulbeConfig?.endpoint ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.stulbeConfigChanged({
|
||||
...stulbeConfig,
|
||||
endpoint: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field name={t('backend.config.username')}>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('backend.config.username-placeholder')}
|
||||
disabled={busy || !active}
|
||||
value={stulbeConfig?.username ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.stulbeConfigChanged({
|
||||
...stulbeConfig,
|
||||
username: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field name={t('backend.config.auth-key')}>
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={t('backend.config.auth-key-placeholder')}
|
||||
disabled={busy || !active}
|
||||
value={stulbeConfig?.auth_key ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.stulbeConfigChanged({
|
||||
...stulbeConfig,
|
||||
auth_key: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setStulbeConfig(stulbeConfig));
|
||||
}}
|
||||
>
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
<button className="button" onClick={test}>
|
||||
{t('actions.test')}
|
||||
</button>
|
||||
{testResult ? (
|
||||
<div className="notification" style={{ marginTop: '1rem' }}>
|
||||
{testResult}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function StulbePage({
|
||||
children,
|
||||
}: RouteComponentProps<React.PropsWithChildren<unknown>>): React.ReactElement {
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -1,189 +0,0 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import eventsubTests from '../../../data/eventsub-tests';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import Stulbe from '../../../lib/stulbe-lib';
|
||||
import { modules } from '../../../store/api/reducer';
|
||||
import { RootState } from '../../../store';
|
||||
|
||||
interface UserData {
|
||||
id: string;
|
||||
login: string;
|
||||
display_name: string;
|
||||
profile_image_url: string;
|
||||
}
|
||||
|
||||
interface SyncError {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const eventSubTestFn = {
|
||||
'channel.update': (send) => {
|
||||
send(eventsubTests['channel.update']);
|
||||
},
|
||||
'channel.follow': (send) => {
|
||||
send(eventsubTests['channel.follow']);
|
||||
},
|
||||
'channel.subscribe': (send) => {
|
||||
send(eventsubTests['channel.subscribe']);
|
||||
},
|
||||
'channel.subscription.gift': (send) => {
|
||||
send(eventsubTests['channel.subscription.gift']);
|
||||
setTimeout(() => {
|
||||
send(eventsubTests['channel.subscribe']);
|
||||
}, 2000);
|
||||
},
|
||||
'channel.subscription.message': (send) => {
|
||||
send(eventsubTests['channel.subscribe']);
|
||||
setTimeout(() => {
|
||||
send(eventsubTests['channel.subscription.message']);
|
||||
}, 2000);
|
||||
},
|
||||
'channel.cheer': (send) => {
|
||||
send(eventsubTests['channel.cheer']);
|
||||
},
|
||||
'channel.raid': (send) => {
|
||||
send(eventsubTests['channel.raid']);
|
||||
},
|
||||
};
|
||||
|
||||
export default function StulbeWebhooksPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const kv = useSelector((state: RootState) => state.api.client);
|
||||
const [stulbeConfig] = useModule(modules.stulbeConfig);
|
||||
const [userStatus, setUserStatus] = useState<UserData | SyncError>(null);
|
||||
const [client, setClient] = useState<Stulbe>(null);
|
||||
|
||||
const getUserInfo = async () => {
|
||||
try {
|
||||
const res = (await client.makeRequest(
|
||||
'GET',
|
||||
'api/twitch/user',
|
||||
)) as UserData;
|
||||
setUserStatus(res);
|
||||
} catch (e) {
|
||||
setUserStatus({ ok: false, error: e.message });
|
||||
}
|
||||
};
|
||||
const startAuthFlow = async () => {
|
||||
const res = (await client.makeRequest('POST', 'api/twitch/authorize')) as {
|
||||
auth_url: string;
|
||||
};
|
||||
const win = window.open(
|
||||
res.auth_url,
|
||||
'_blank',
|
||||
'height=800,width=520,scrollbars=yes,status=yes',
|
||||
);
|
||||
// Hack, have to poll because no events are reliable for this
|
||||
const iv = setInterval(() => {
|
||||
if (win.closed) {
|
||||
clearInterval(iv);
|
||||
setUserStatus(null);
|
||||
getUserInfo();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const sendFakeEvent = async (event: keyof typeof eventSubTestFn) => {
|
||||
eventSubTestFn[event]((data) => {
|
||||
kv.putJSON('stulbe/ev/webhook', {
|
||||
...data,
|
||||
subscription: {
|
||||
...data.subscription,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
// Get user info
|
||||
getUserInfo();
|
||||
} else if (
|
||||
stulbeConfig &&
|
||||
stulbeConfig.enabled &&
|
||||
stulbeConfig.endpoint &&
|
||||
stulbeConfig.auth_key &&
|
||||
stulbeConfig.username
|
||||
) {
|
||||
const tryAuth = async () => {
|
||||
// Try authenticating
|
||||
const stulbeClient = new Stulbe(stulbeConfig.endpoint);
|
||||
await stulbeClient.auth(stulbeConfig.username, stulbeConfig.auth_key);
|
||||
setClient(stulbeClient);
|
||||
};
|
||||
tryAuth();
|
||||
}
|
||||
}, [stulbeConfig, client]);
|
||||
|
||||
if (!stulbeConfig.enabled) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('backend.webhook.err-not-enabled')}</h1>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let userBlock = <i>{t('backend.webhook.loading')}</i>;
|
||||
if (userStatus !== null) {
|
||||
if ('id' in userStatus) {
|
||||
userBlock = (
|
||||
<>
|
||||
<div
|
||||
className="is-flex"
|
||||
style={{ alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<p>{t('backend.config.authenticated')}</p>
|
||||
<img
|
||||
style={{ width: '20px', borderRadius: '5px' }}
|
||||
src={userStatus.profile_image_url}
|
||||
alt="Profile picture"
|
||||
/>
|
||||
<b>{userStatus.display_name}</b>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
userBlock = t('backend.webhook.err-no-user');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('backend.webhook.header')}</h1>
|
||||
<div className="box">
|
||||
<div className="title is-5" style={{ marginBottom: '0.75rem' }}>
|
||||
{t('backend.webhook.current-status')}
|
||||
</div>
|
||||
<p>{userBlock}</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<p>{t('backend.webhook.auth-message')}</p>
|
||||
</div>
|
||||
<button className="button" onClick={startAuthFlow} disabled={!client}>
|
||||
{t('backend.webhook.auth-button')}
|
||||
</button>
|
||||
<h2 className="title is-4" style={{ marginTop: '2rem' }}>
|
||||
{t('backend.webhook.fake-header')}
|
||||
</h2>
|
||||
{Object.keys(eventSubTestFn).map((ev: keyof typeof eventsubTests) => (
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => sendFakeEvent(ev)}
|
||||
style={{ margin: '0.2rem' }}
|
||||
>
|
||||
{t(`backend.webhook.sim-${ev}`, { defaultValue: ev })}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
import Field from '../../components/Field';
|
||||
|
||||
export default function TwitchBotSettingsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = twitchConfig === null;
|
||||
const active = twitchConfig?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.config.header')}</h1>
|
||||
<Field>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
disabled={busy}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchConfigChanged({
|
||||
...twitchConfig,
|
||||
enabled: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.config.enable')}
|
||||
</label>
|
||||
</Field>
|
||||
<div className="copyblock">
|
||||
<p>{t('twitch.config.apiguide-1')}</p>
|
||||
<p>
|
||||
{'- '}
|
||||
<Trans i18nKey="twitch.config.apiguide-2">
|
||||
{'Go to '}
|
||||
<a href="https://dev.twitch.tv/console/apps/create">
|
||||
https://dev.twitch.tv/console/apps/create
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
{'- '}
|
||||
{t('twitch.config.apiguide-3')}
|
||||
</p>
|
||||
<dl className="inline-dl">
|
||||
<dt>OAuth Redirect URLs</dt>
|
||||
<dd>http://localhost:4337/oauth</dd>
|
||||
<dt>Category</dt>
|
||||
<dd>Broadcasting Suite</dd>
|
||||
</dl>
|
||||
{'- '}
|
||||
<Trans i18nKey="twitch.config.apiguide-4">
|
||||
Once created, create a <b>New Secret</b>, then copy both fields below!
|
||||
</Trans>
|
||||
</div>
|
||||
<Field name={t('twitch.config.app-client-id')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('twitch.config.app-client-id')}
|
||||
value={twitchConfig?.api_client_id ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchConfigChanged({
|
||||
...twitchConfig,
|
||||
api_client_id: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field name={t('twitch.config.app-client-secret')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={t('twitch.config.app-client-secret')}
|
||||
value={twitchConfig?.api_client_secret ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchConfigChanged({
|
||||
...twitchConfig,
|
||||
api_client_secret: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setTwitchConfig(twitchConfig));
|
||||
}}
|
||||
>
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,419 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
import Field from '../../components/Field';
|
||||
import MessageArray from '../../components/MessageArray';
|
||||
import TabbedView from '../../components/TabbedView';
|
||||
|
||||
export default function TwitchBotAlertsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [twitchConfig] = useModule(modules.twitchConfig);
|
||||
const [stulbeConfig] = useModule(modules.stulbeConfig);
|
||||
const [twitchBotAlerts, setTwitchBotAlerts] = useModule(
|
||||
modules.twitchBotAlerts,
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const botActive = twitchConfig?.enable_bot ?? false;
|
||||
const stulbeActive = stulbeConfig?.enabled ?? false;
|
||||
|
||||
if (!botActive) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.bot-alerts.header')}</h1>
|
||||
<p>{t('twitch.bot-alerts.err-twitchbot-disabled')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stulbeActive) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.bot-alerts.header')}</h1>
|
||||
<p>{t('twitch.bot-alerts.err-stulbe-disabled')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.bot-alerts.header')}</h1>
|
||||
<TabbedView>
|
||||
<article data-name={t('twitch.bot-alerts.follow')}>
|
||||
<Field horizontal>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twitchBotAlerts?.follow?.enabled ?? false}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
follow: {
|
||||
...twitchBotAlerts.follow,
|
||||
enabled: ev.target.checked,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot-alerts.follow-enabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot-alerts.messages')}>
|
||||
<MessageArray
|
||||
value={twitchBotAlerts?.follow?.messages ?? ['']}
|
||||
onChange={(messages) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
follow: {
|
||||
...twitchBotAlerts.follow,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</article>
|
||||
<article data-name={t('twitch.bot-alerts.subscription')}>
|
||||
<Field horizontal>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twitchBotAlerts?.subscription?.enabled ?? false}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
enabled: ev.target.checked,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot-alerts.sub-enabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot-alerts.messages')}>
|
||||
<MessageArray
|
||||
value={twitchBotAlerts?.subscription?.messages ?? ['']}
|
||||
onChange={(messages) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<hr />
|
||||
<section className="variations">
|
||||
<h3 className="title is-5">
|
||||
{t('twitch.bot-alerts.variation-header')}
|
||||
</h3>
|
||||
{twitchBotAlerts?.subscription?.variations?.map((variation, i) => (
|
||||
<article key={i} className="box">
|
||||
<Field name={t('twitch.bot-alerts.variation-condition')}>
|
||||
<div className="control">
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={`sub-var-${i}`}
|
||||
checked={variation.is_gifted ?? false}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
variations:
|
||||
// Replace messages in nth variation
|
||||
twitchBotAlerts?.subscription?.variations.map(
|
||||
(v, j) =>
|
||||
j === i
|
||||
? {
|
||||
...v,
|
||||
is_gifted: ev.target.checked,
|
||||
min_streak: null,
|
||||
}
|
||||
: v,
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Is gifted
|
||||
</label>
|
||||
</div>
|
||||
<div className="control">
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!!variation.min_streak}
|
||||
name={`sub-var-${i}`}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
variations:
|
||||
// Replace messages in nth variation
|
||||
twitchBotAlerts?.subscription?.variations.map(
|
||||
(v, j) =>
|
||||
j === i
|
||||
? {
|
||||
...v,
|
||||
min_streak: ev.target.checked
|
||||
? 1
|
||||
: null,
|
||||
is_gifted: false,
|
||||
}
|
||||
: v,
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Subscription months
|
||||
</label>
|
||||
</div>
|
||||
</Field>
|
||||
{variation.min_streak ? (
|
||||
<Field name={t('twitch.bot-alerts.min-months')}>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min="1"
|
||||
step="1"
|
||||
value={variation.min_streak}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
variations:
|
||||
// Replace messages in nth variation
|
||||
twitchBotAlerts?.subscription?.variations.map(
|
||||
(v, j) =>
|
||||
j === i
|
||||
? {
|
||||
...v,
|
||||
min_streak: ev.target.value,
|
||||
}
|
||||
: v,
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field name={t('twitch.bot-alerts.messages')}>
|
||||
<MessageArray
|
||||
value={variation?.messages ?? ['']}
|
||||
onChange={(messages) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
variations:
|
||||
// Replace messages in nth variation
|
||||
twitchBotAlerts?.subscription?.variations.map(
|
||||
(v, j) => (j === i ? { ...v, messages } : v),
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<button
|
||||
className="button is-small is-danger"
|
||||
onClick={() => {
|
||||
const variations =
|
||||
twitchBotAlerts?.subscription?.variations ?? [];
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
variations: variations.filter((v, j) => j !== i),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('twitch.bot-alerts.delete-variation')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
<button
|
||||
className="button is-small is-success"
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
subscription: {
|
||||
...twitchBotAlerts.subscription,
|
||||
variations: [
|
||||
...(twitchBotAlerts?.subscription?.variations ?? []),
|
||||
{
|
||||
messages: [''],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('twitch.bot-alerts.add-variation')}
|
||||
</button>
|
||||
</section>
|
||||
</article>
|
||||
<article data-name={t('twitch.bot-alerts.gift-sub')}>
|
||||
<Field horizontal>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twitchBotAlerts?.gift_sub?.enabled ?? false}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
gift_sub: {
|
||||
...twitchBotAlerts.gift_sub,
|
||||
enabled: ev.target.checked,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot-alerts.gift-sub-enabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot-alerts.messages')}>
|
||||
<MessageArray
|
||||
value={twitchBotAlerts?.gift_sub?.messages ?? ['']}
|
||||
onChange={(messages) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
gift_sub: {
|
||||
...twitchBotAlerts.gift_sub,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</article>
|
||||
<article data-name={t('twitch.bot-alerts.raid')}>
|
||||
<Field horizontal>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twitchBotAlerts?.raid?.enabled ?? false}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
raid: {
|
||||
...twitchBotAlerts.raid,
|
||||
enabled: ev.target.checked,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot-alerts.raid-enabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot-alerts.messages')}>
|
||||
<MessageArray
|
||||
value={twitchBotAlerts?.raid?.messages ?? ['']}
|
||||
onChange={(messages) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
raid: {
|
||||
...twitchBotAlerts.raid,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</article>
|
||||
<article data-name={t('twitch.bot-alerts.cheer')}>
|
||||
<Field horizontal>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twitchBotAlerts?.cheer?.enabled ?? false}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
cheer: {
|
||||
...twitchBotAlerts.cheer,
|
||||
enabled: ev.target.checked,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot-alerts.cheer-enabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot-alerts.messages')}>
|
||||
<MessageArray
|
||||
value={twitchBotAlerts?.cheer?.messages ?? ['']}
|
||||
onChange={(messages) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotAlertsChanged({
|
||||
...twitchBotAlerts,
|
||||
cheer: {
|
||||
...twitchBotAlerts.cheer,
|
||||
messages,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</article>
|
||||
</TabbedView>
|
||||
<br />
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setTwitchBotAlerts(twitchBotAlerts));
|
||||
}}
|
||||
>
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,171 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import apiReducer, { modules } from '../../../store/api/reducer';
|
||||
import Field from '../../components/Field';
|
||||
|
||||
export default function TwitchBotSettingsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
|
||||
const [twitchBotConfig, setTwitchBotConfig] = useModule(
|
||||
modules.twitchBotConfig,
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const busy = twitchConfig === null;
|
||||
const twitchActive = twitchConfig?.enabled ?? false;
|
||||
const botActive = twitchConfig?.enable_bot ?? false;
|
||||
const active = twitchActive && botActive;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.bot.header')}</h1>
|
||||
<Field>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={botActive}
|
||||
disabled={!twitchActive || busy}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchConfigChanged({
|
||||
...twitchConfig,
|
||||
enable_bot: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot.enable')}
|
||||
{twitchActive ? '' : t('twitch.bot.err-module-disabled')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot.channel-name')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('twitch.bot.channel-name')}
|
||||
value={twitchBotConfig?.channel ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
channel: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field
|
||||
name={`${t('twitch.bot.username')} (${t('twitch.bot.username-expl')})`}
|
||||
>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('twitch.bot.username')}
|
||||
value={twitchBotConfig?.username ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
username: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot.oauth-token')}>
|
||||
<p className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="password"
|
||||
placeholder={t('twitch.bot.oauth-token')}
|
||||
value={twitchBotConfig?.oauth ?? ''}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
oauth: ev.target.value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
<p className="help">
|
||||
<Trans i18nKey="twitch.bot.oauth-help">
|
||||
{
|
||||
'You can get this by logging in with the bot account and going here: '
|
||||
}
|
||||
<a href="https://twitchapps.com/tmi/">
|
||||
https://twitchapps.com/tmi/
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
</Field>
|
||||
<Field>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={twitchBotConfig?.chat_keys ?? false}
|
||||
disabled={busy}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
chat_keys: ev.target.checked,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>{' '}
|
||||
{t('twitch.bot.chat-keys')}
|
||||
</label>
|
||||
</Field>
|
||||
<Field name={t('twitch.bot.chat-history')}>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons">
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
disabled={!twitchBotConfig?.chat_keys ?? true}
|
||||
placeholder="#"
|
||||
value={twitchBotConfig?.chat_history ?? '5'}
|
||||
onChange={(ev) =>
|
||||
dispatch(
|
||||
apiReducer.actions.twitchBotConfigChanged({
|
||||
...twitchBotConfig,
|
||||
chat_history: parseInt(ev.target.value, 10) ?? 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
<p className="control">
|
||||
<a className="button is-static">{t('twitch.bot.suf-messages')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
dispatch(setTwitchConfig(twitchConfig));
|
||||
dispatch(setTwitchBotConfig(twitchBotConfig));
|
||||
}}
|
||||
>
|
||||
{t('actions.save')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,331 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { modules } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
import {
|
||||
AccessLevelType,
|
||||
TwitchBotCustomCommand,
|
||||
} from '../../../store/api/types';
|
||||
import Field from '../../components/Field';
|
||||
|
||||
interface CommandItemProps {
|
||||
name: string;
|
||||
item: TwitchBotCustomCommand;
|
||||
onToggleState: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
function CommandItem({
|
||||
name,
|
||||
item,
|
||||
onToggleState,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CommandItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="card customcommand" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
{item.enabled ? (
|
||||
<code>{name}</code>
|
||||
) : (
|
||||
<span className="reward-disabled">
|
||||
<code>{name}</code>
|
||||
</span>
|
||||
)}{' '}
|
||||
{item.description}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
{t('twitch.commands.response')}:{' '}
|
||||
<blockquote>{item.response}</blockquote>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? t('actions.disable') : t('actions.enable')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
{t('actions.edit')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
{t('actions.delete')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommandModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (newName: string, r: TwitchBotCustomCommand) => void;
|
||||
onClose: () => void;
|
||||
initialData?: TwitchBotCustomCommand;
|
||||
initialName?: string;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function CommandModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialName,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: CommandModalProps) {
|
||||
const [name, setName] = useState(initialName ?? '');
|
||||
const [description, setDescription] = useState(
|
||||
initialData?.description ?? '',
|
||||
);
|
||||
const [accessLevel, setAccessLevel] = useState(
|
||||
initialData?.access_level ?? 'everyone',
|
||||
);
|
||||
const [response, setResponse] = useState(initialData?.response ?? '');
|
||||
|
||||
const { t } = useTranslation();
|
||||
const slugify = (str: string) =>
|
||||
str.toLowerCase().replace(/[^a-zA-Z0-9!.-_@:;'"<>]/gi, '-');
|
||||
const validForm = name !== '' && response !== '';
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(name, {
|
||||
description,
|
||||
response,
|
||||
enabled: initialData?.enabled ?? false,
|
||||
access_level: accessLevel,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<Field name={t('twitch.commands.command')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<input
|
||||
className={name !== '' ? 'input' : 'input is-danger'}
|
||||
type="text"
|
||||
placeholder="!mycommand"
|
||||
value={name}
|
||||
onChange={(ev) => setName(slugify(ev.target.value))}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('twitch.commands.description')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder={t('twitch.commands.description-help')}
|
||||
rows={1}
|
||||
onChange={(ev) => setDescription(ev.target.value)}
|
||||
value={description}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('twitch.commands.response')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<textarea
|
||||
className={response !== '' ? 'textarea' : 'textarea is-danger'}
|
||||
placeholder={t('twitch.commands.response-help')}
|
||||
onChange={(ev) => setResponse(ev.target.value)}
|
||||
value={response}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('twitch.commands.access-level')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<p className="control">
|
||||
<span className="select">
|
||||
<select
|
||||
value={accessLevel}
|
||||
onChange={(ev) =>
|
||||
setAccessLevel(ev.target.value as AccessLevelType)
|
||||
}
|
||||
>
|
||||
<option value="everyone">
|
||||
{t('twitch.commands.access-everyone')}
|
||||
</option>
|
||||
<option value="subscribers">
|
||||
{t('twitch.commands.access-subscribers')}
|
||||
</option>
|
||||
<option value="vip">
|
||||
{t('twitch.commands.access-vips')}
|
||||
</option>
|
||||
<option value="moderators">
|
||||
{t('twitch.commands.access-moderators')}
|
||||
</option>
|
||||
<option value="streamer">
|
||||
{t('twitch.commands.access-streamer')}
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</p>
|
||||
<p className="help">{t('twitch.commands.access-level-help')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TwitchBotCommandsPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [commands, setCommands] = useModule(modules.twitchBotCommands);
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [showModifyCommand, setShowModifyCommand] = useState(null);
|
||||
const [commandFilter, setCommandFilter] = useState('');
|
||||
const commandFilterLC = commandFilter.toLowerCase();
|
||||
|
||||
const createCommand = (cmd: string, data: TwitchBotCustomCommand): void => {
|
||||
dispatch(
|
||||
setCommands({
|
||||
...commands,
|
||||
[cmd]: data,
|
||||
}),
|
||||
);
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
const modifyCommand = (
|
||||
oldName: string,
|
||||
newName: string,
|
||||
data: TwitchBotCustomCommand,
|
||||
): void => {
|
||||
dispatch(
|
||||
setCommands({
|
||||
...commands,
|
||||
[oldName]: undefined,
|
||||
[newName]: {
|
||||
...commands[oldName],
|
||||
...data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
setShowModifyCommand(null);
|
||||
};
|
||||
|
||||
const deleteCommand = (cmd: string): void => {
|
||||
dispatch(
|
||||
setCommands({
|
||||
...commands,
|
||||
[cmd]: undefined,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleCommand = (cmd: string): void => {
|
||||
dispatch(
|
||||
setCommands({
|
||||
...commands,
|
||||
[cmd]: {
|
||||
...commands[cmd],
|
||||
enabled: !commands[cmd].enabled,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.commands.header')}</h1>
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button className="button" onClick={() => setCreateModal(true)}>
|
||||
{t('twitch.commands.new-command')}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('twitch.commands.search')}
|
||||
value={commandFilter}
|
||||
onChange={(ev) => setCommandFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CommandModal
|
||||
title={t('twitch.commands.new-command')}
|
||||
confirmText={t('actions.create')}
|
||||
active={createModal}
|
||||
onConfirm={createCommand}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyCommand ? (
|
||||
<CommandModal
|
||||
title={t('twitch.commands.modify-command')}
|
||||
confirmText={t('actions.edit')}
|
||||
active={true}
|
||||
onConfirm={(newName, cmdData) =>
|
||||
modifyCommand(showModifyCommand, newName, cmdData)
|
||||
}
|
||||
initialName={showModifyCommand}
|
||||
initialData={showModifyCommand ? commands[showModifyCommand] : null}
|
||||
onClose={() => setShowModifyCommand(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="reward-list" style={{ marginTop: '1rem' }}>
|
||||
{Object.keys(commands ?? {})
|
||||
?.filter((cmd) => cmd.toLowerCase().includes(commandFilterLC))
|
||||
.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd}
|
||||
name={cmd}
|
||||
item={commands[cmd]}
|
||||
onDelete={() => deleteCommand(cmd)}
|
||||
onEdit={() => setShowModifyCommand(cmd)}
|
||||
onToggleState={() => toggleCommand(cmd)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React from 'react';
|
||||
|
||||
export default function TwitchBotPage({
|
||||
children,
|
||||
}: RouteComponentProps<React.PropsWithChildren<unknown>>): React.ReactElement {
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -1,343 +0,0 @@
|
|||
import { RouteComponentProps } from '@reach/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import prettyTime from 'pretty-ms';
|
||||
import { useModule } from '../../../lib/react-utils';
|
||||
import { modules } from '../../../store/api/reducer';
|
||||
import Modal from '../../components/Modal';
|
||||
import { TwitchBotTimer } from '../../../store/api/types';
|
||||
import Field from '../../components/Field';
|
||||
import Interval, { hours, minutes } from '../../components/Interval';
|
||||
import MessageArray from '../../components/MessageArray';
|
||||
|
||||
interface TimerItemProps {
|
||||
item: TwitchBotTimer;
|
||||
onToggleState: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
function TimerItem({ item, onToggleState, onEdit, onDelete }: TimerItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="card customcommand" style={{ marginBottom: '3px' }}>
|
||||
<header className="card-header">
|
||||
<div className="card-header-title">
|
||||
{item.enabled ? (
|
||||
<>
|
||||
<code>{item.name}</code> (
|
||||
{t('twitch.timers.condition-text', {
|
||||
time: prettyTime(item.minimum_delay * 1000),
|
||||
messages: item.minimum_chat_activity,
|
||||
})}
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<span className="reward-disabled">
|
||||
<code>{item.name}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className="card-header-icon"
|
||||
aria-label="expand"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className={expanded ? 'icon expand-off' : 'icon expand-on'}>
|
||||
❯
|
||||
</span>
|
||||
</a>
|
||||
</header>
|
||||
{expanded ? (
|
||||
<div className="content">
|
||||
{t('twitch.timers.messages')}:{' '}
|
||||
{item.messages.map((message, index) => (
|
||||
<blockquote key={index}>{message}</blockquote>
|
||||
))}
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<a className="button is-small" onClick={onToggleState}>
|
||||
{item.enabled ? t('actions.disable') : t('actions.enable')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onEdit}>
|
||||
{t('actions.edit')}
|
||||
</a>{' '}
|
||||
<a className="button is-small" onClick={onDelete}>
|
||||
{t('actions.delete')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimerModalProps {
|
||||
active: boolean;
|
||||
onConfirm: (newName: string, r: TwitchBotTimer) => void;
|
||||
onClose: () => void;
|
||||
initialData?: TwitchBotTimer;
|
||||
initialName?: string;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
}
|
||||
|
||||
function TimerModal({
|
||||
active,
|
||||
onConfirm,
|
||||
onClose,
|
||||
initialName,
|
||||
initialData,
|
||||
title,
|
||||
confirmText,
|
||||
}: TimerModalProps) {
|
||||
const [name, setName] = useState(initialName ?? '');
|
||||
const [messages, setMessages] = useState(initialData?.messages ?? ['']);
|
||||
const [minDelay, setMinDelay] = useState(initialData?.minimum_delay ?? 300);
|
||||
const [minActivity, setMinActivity] = useState(
|
||||
initialData?.minimum_chat_activity ?? 5,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const validForm =
|
||||
name !== '' && messages.length > 0 && messages.every((msg) => msg !== '');
|
||||
|
||||
const confirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm(name, {
|
||||
name,
|
||||
messages,
|
||||
minimum_chat_activity: minActivity,
|
||||
minimum_delay: minDelay,
|
||||
enabled: initialData?.enabled ?? false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
active={active}
|
||||
title={title}
|
||||
showCancel={true}
|
||||
bgDismiss={true}
|
||||
confirmName={confirmText}
|
||||
confirmClass="is-success"
|
||||
confirmEnabled={validForm}
|
||||
onConfirm={() => confirm()}
|
||||
onClose={() => onClose()}
|
||||
>
|
||||
<Field name={t('twitch.timers.name')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<input
|
||||
className={name !== '' ? 'input' : 'input is-danger'}
|
||||
type="text"
|
||||
placeholder={t('twitch.timers.name-hint')}
|
||||
value={name}
|
||||
onChange={(ev) => setName(ev.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('twitch.timers.minimum-delay')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons" style={{ marginBottom: 0 }}>
|
||||
<Interval
|
||||
value={minDelay}
|
||||
onChange={setMinDelay}
|
||||
active={active}
|
||||
min={60}
|
||||
units={[minutes, hours]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('twitch.timers.minimum-activity')} horizontal>
|
||||
<div className="field-body">
|
||||
<div className="field has-addons" style={{ marginBottom: 0 }}>
|
||||
<div className="control">
|
||||
<input
|
||||
disabled={!active}
|
||||
className="input"
|
||||
type="number"
|
||||
placeholder="#"
|
||||
style={{ width: '6rem' }}
|
||||
value={minActivity ?? 0}
|
||||
onChange={(ev) => {
|
||||
const amount = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(amount)) {
|
||||
return;
|
||||
}
|
||||
setMinActivity(amount);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="control">
|
||||
<a className="button is-static">
|
||||
{t('twitch.timers.minimum-activity-post')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field name={t('twitch.timers.messages')} horizontal>
|
||||
<div className="field-body">
|
||||
<MessageArray
|
||||
value={messages}
|
||||
placeholder={t('twitch.timers.message-help')}
|
||||
onChange={(changed) => setMessages(changed)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TwitchBotTimersPage(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
props: RouteComponentProps<unknown>,
|
||||
): React.ReactElement {
|
||||
const [twitchConfig] = useModule(modules.twitchConfig);
|
||||
const [timerConfig, setTimerConfig] = useModule(modules.twitchBotTimers);
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [showModifyTimer, setShowModifyTimer] = useState(null);
|
||||
const [timerFilter, setTimerFilter] = useState('');
|
||||
const timerFilterLC = timerFilter.toLowerCase();
|
||||
|
||||
const botActive = twitchConfig?.enable_bot ?? false;
|
||||
|
||||
const createTimer = (name: string, data: TwitchBotTimer): void => {
|
||||
dispatch(
|
||||
setTimerConfig({
|
||||
...timerConfig,
|
||||
timers: {
|
||||
...timerConfig.timers,
|
||||
[name]: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
setCreateModal(false);
|
||||
};
|
||||
|
||||
const modifyTimer = (
|
||||
oldName: string,
|
||||
newName: string,
|
||||
data: TwitchBotTimer,
|
||||
): void => {
|
||||
dispatch(
|
||||
setTimerConfig({
|
||||
...timerConfig,
|
||||
timers: {
|
||||
...timerConfig.timers,
|
||||
[oldName]: undefined,
|
||||
[newName]: {
|
||||
...timerConfig.timers[oldName],
|
||||
...data,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
setShowModifyTimer(null);
|
||||
};
|
||||
|
||||
const deleteTimer = (cmd: string): void => {
|
||||
dispatch(
|
||||
setTimerConfig({
|
||||
...timerConfig,
|
||||
timers: {
|
||||
...timerConfig.timers,
|
||||
[cmd]: undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleTimer = (cmd: string): void => {
|
||||
dispatch(
|
||||
setTimerConfig({
|
||||
...timerConfig,
|
||||
timers: {
|
||||
...timerConfig.timers,
|
||||
[cmd]: {
|
||||
...timerConfig.timers[cmd],
|
||||
enabled: !timerConfig.timers[cmd].enabled,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if (!botActive) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.timers.header')}</h1>
|
||||
<p>{t('twitch.timers.err-twitchbot-disabled')}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="title is-4">{t('twitch.timers.header')}</h1>
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<button className="button" onClick={() => setCreateModal(true)}>
|
||||
{t('twitch.timers.new-timer')}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="control">
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('twitch.timers.search')}
|
||||
value={timerFilter}
|
||||
onChange={(ev) => setTimerFilter(ev.target.value)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TimerModal
|
||||
title={t('twitch.timers.new-timer')}
|
||||
confirmText={t('actions.create')}
|
||||
active={createModal}
|
||||
onConfirm={createTimer}
|
||||
onClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{showModifyTimer ? (
|
||||
<TimerModal
|
||||
title={t('twitch.timers.modify-timer')}
|
||||
confirmText={t('actions.edit')}
|
||||
active={true}
|
||||
onConfirm={(newName, cmdData) =>
|
||||
modifyTimer(showModifyTimer, newName, cmdData)
|
||||
}
|
||||
initialName={showModifyTimer}
|
||||
initialData={
|
||||
showModifyTimer ? timerConfig.timers[showModifyTimer] : null
|
||||
}
|
||||
onClose={() => setShowModifyTimer(null)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="reward-list" style={{ marginTop: '1rem' }}>
|
||||
{Object.keys(timerConfig?.timers ?? {})
|
||||
?.filter((cmd) => cmd.toLowerCase().includes(timerFilterLC))
|
||||
.map((timer) => (
|
||||
<TimerItem
|
||||
key={timer}
|
||||
item={timerConfig.timers[timer]}
|
||||
onDelete={() => deleteTimer(timer)}
|
||||
onEdit={() => setShowModifyTimer(timer)}
|
||||
onToggleState={() => toggleTimer(timer)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue