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": "^17.0.5",
|
||||||
"@types/react-dom": "^17.0.4",
|
"@types/react-dom": "^17.0.4",
|
||||||
"@vitejs/plugin-react": "^1.0.9",
|
"@vitejs/plugin-react": "^1.0.9",
|
||||||
"bulma": "^0.9.2",
|
|
||||||
"i18next": "^20.6.1",
|
"i18next": "^20.6.1",
|
||||||
"pretty-ms": "^7.0.1",
|
"pretty-ms": "^7.0.1",
|
||||||
"react": "^17.0.2",
|
"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 from 'react';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
import { RootState } from '../store';
|
export default function App(): JSX.Element {
|
||||||
import { createWSClient } from '../store/api/reducer';
|
return <main></main>;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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