1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

Clean slate!

This commit is contained in:
Ash Keel 2021-12-09 10:45:38 +01:00
parent d13e47d3ce
commit dc39bc0cd9
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
28 changed files with 4 additions and 4604 deletions

View file

@ -11,7 +11,6 @@
"@types/react": "^17.0.5",
"@types/react-dom": "^17.0.4",
"@vitejs/plugin-react": "^1.0.9",
"bulma": "^0.9.2",
"i18next": "^20.6.1",
"pretty-ms": "^7.0.1",
"react": "^17.0.2",

View file

@ -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"
}
}
{}

View file

@ -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: ':';
}

View file

@ -1,215 +1,5 @@
import { Link, Redirect, Router, useLocation } from '@reach/router';
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import React from 'react';
import { RootState } from '../store';
import { createWSClient } from '../store/api/reducer';
import Home from './pages/Home';
import HTTPPage from './pages/HTTP';
import TwitchPage from './pages/twitch/Main';
import StulbePage from './pages/stulbe/Main';
import LoyaltyPage from './pages/loyalty/Main';
import DebugPage from './pages/Debug';
import LoyaltySettingPage from './pages/loyalty/Settings';
import LoyaltyRewardsPage from './pages/loyalty/Rewards';
import LoyaltyUserListPage from './pages/loyalty/UserList';
import LoyaltyGoalsPage from './pages/loyalty/Goals';
import LoyaltyRedeemQueuePage from './pages/loyalty/Queue';
import TwitchSettingsPage from './pages/twitch/APISettings';
import TwitchBotSettingsPage from './pages/twitch/BotSettings';
import TwitchBotCommandsPage from './pages/twitch/Commands';
import StulbeConfigPage from './pages/stulbe/Config';
import StulbeWebhooksPage from './pages/stulbe/Webhook';
import TwitchBotTimersPage from './pages/twitch/Timers';
import { ConnectionStatus } from '../store/api/types';
import Field from './components/Field';
import TwitchBotAlertsPage from './pages/twitch/Alerts';
interface RouteItem {
name?: string;
route: string;
subroutes?: RouteItem[];
}
const menu: RouteItem[] = [
{ route: '/' },
{ route: '/http' },
{
route: '/twitch',
subroutes: [
{ route: '/twitch/settings' },
{ route: '/twitch/bot/settings' },
{ route: '/twitch/bot/commands' },
{ route: '/twitch/bot/timers' },
{ route: '/twitch/bot/alerts' },
],
},
{
route: '/loyalty',
subroutes: [
{ route: '/loyalty/settings' },
{ route: '/loyalty/users' },
{ route: '/loyalty/queue' },
{ route: '/loyalty/rewards' },
{ route: '/loyalty/goals' },
],
},
{
route: '/stulbe',
subroutes: [{ route: '/stulbe/config' }, { route: '/stulbe/webhooks' }],
},
];
function AuthModal(): React.ReactElement {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const inputRef = useRef<HTMLInputElement>();
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
});
const submit = () => {
localStorage.setItem('password', password);
window.location.reload();
};
return (
<div className="modal is-active">
<div className="modal-background"></div>
<div className="modal-card">
<header className="modal-card-head">
<p className="modal-card-title">{t('auth.header')}</p>
</header>
<section className="modal-card-body">
<Field>{t('auth.message')}</Field>
<Field>
<input
className="input"
type="password"
placeholder={t('auth.placeholder')}
value={password ?? ''}
ref={inputRef}
onChange={(ev) => setPassword(ev.target.value)}
onKeyUp={(ev) => {
if (ev.key === 'Enter' || ev.code === 'Enter') {
submit();
}
}}
/>
</Field>
</section>
<footer className="modal-card-foot">
<button className="button is-success" onClick={() => submit()}>
{t('auth.button')}
</button>
</footer>
</div>
</div>
);
}
export default function App(): React.ReactElement {
const loc = useLocation();
const { t } = useTranslation();
const client = useSelector((state: RootState) => state.api.client);
const connected = useSelector(
(state: RootState) => state.api.connectionStatus,
);
const dispatch = useDispatch();
// Create WS client
useEffect(() => {
if (!client) {
dispatch(
createWSClient({
address:
process.env.NODE_ENV === 'development'
? 'ws://localhost:4337/ws'
: `ws://${loc.host}/ws`,
password: localStorage.password,
}),
);
}
}, []);
if (connected === ConnectionStatus.AuthenticationNeeded) {
return <AuthModal />;
}
if (!client) {
return <div className="container">{t('system.loading')}</div>;
}
const basepath = '/ui/';
const routeItem = ({ route, name, subroutes }: RouteItem) => (
<li key={route}>
<Link
getProps={({ isPartiallyCurrent, isCurrent }) => {
const active = isCurrent || (subroutes && isPartiallyCurrent);
return {
className: active ? 'is-active' : '',
};
}}
to={`${basepath}${route}`.replace(/\/\//gi, '/')}
>
{name ?? t(`pages.${route}`)}
</Link>
{subroutes ? (
<ul className="subroute">{subroutes.map(routeItem)}</ul>
) : null}
</li>
);
return (
<section className="main-content columns is-fullheight">
<section className="notifications">
{connected !== ConnectionStatus.Connected ? (
<div className="notification is-danger">
{t('system.connection-lost')}
</div>
) : null}
</section>
<aside className="menu sidebar column is-3 is-fullheight section">
<p className="menu-label is-hidden-touch">{t('system.menu-header')}</p>
<ul className="menu-list">{menu.map(routeItem)}</ul>
</aside>
<div className="app-content column is-9">
<div className="content-pad">
<Router basepath={basepath}>
<Home path="/" />
<HTTPPage path="http" />
<TwitchPage path="twitch">
<Redirect from="/" to="settings" noThrow />
<TwitchSettingsPage path="settings" />
<TwitchBotSettingsPage path="bot/settings" />
<TwitchBotCommandsPage path="bot/commands" />
<TwitchBotTimersPage path="bot/timers" />
<TwitchBotAlertsPage path="bot/alerts" />
</TwitchPage>
<LoyaltyPage path="loyalty">
<Redirect from="/" to="settings" noThrow />
<LoyaltySettingPage path="settings" />
<LoyaltyUserListPage path="users" />
<LoyaltyRedeemQueuePage path="queue" />
<LoyaltyRewardsPage path="rewards" />
<LoyaltyGoalsPage path="goals" />
</LoyaltyPage>
<StulbePage path="stulbe">
<Redirect from="/" to="config" noThrow />
<StulbeConfigPage path="config" />
<StulbeWebhooksPage path="webhooks" />
</StulbePage>
<DebugPage path="debug" />
</Router>
</div>
</div>
</section>
);
export default function App(): JSX.Element {
return <main></main>;
}

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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)}
>
&lsaquo;
</button>
<button
className="button pagination-next"
disabled={current >= max}
onClick={() => onPageChange(current + 1)}
>
&rsaquo;
</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">&hellip;</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">&hellip;</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);

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>;
}

View file

@ -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>
</>
);
}

View file

@ -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}</>;
}

View file

@ -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>
)}
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
)}
</>
);
}

View file

@ -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}
</>
);
}

View file

@ -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}</>;
}

View file

@ -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>
))}
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
</>
);
}

View file

@ -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}</>;
}

View file

@ -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>
</>
);
}