mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-18 01:50:50 +00:00
Auth dialog, chat timers, some reusable components
This commit is contained in:
parent
64366a7903
commit
0b7f7fe069
14 changed files with 843 additions and 86 deletions
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
|
@ -2708,11 +2708,6 @@
|
|||
"error-ex": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"parse-ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz",
|
||||
"integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA=="
|
||||
},
|
||||
"path-exists": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
|
||||
|
@ -2812,14 +2807,6 @@
|
|||
"fast-diff": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"pretty-ms": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz",
|
||||
"integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==",
|
||||
"requires": {
|
||||
"parse-ms": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
|
|
|
@ -24,7 +24,6 @@
|
|||
"overlayscrollbars": "^1.13.1",
|
||||
"overlayscrollbars-react": "^0.2.3",
|
||||
"postcss-import": "^14.0.2",
|
||||
"pretty-ms": "^7.0.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^11.12.0",
|
||||
|
|
|
@ -125,6 +125,31 @@
|
|||
"streamer": "Streamer only"
|
||||
},
|
||||
"remove-command-title": "Remove command {{name}}?"
|
||||
},
|
||||
"bottimers": {
|
||||
"title": "Bot timers",
|
||||
"desc": "Define reminders such as checking out your social media or ongoing events",
|
||||
"add-button": "New timer",
|
||||
"search-placeholder": "Search timer by name",
|
||||
"timer-header-new": "New timer",
|
||||
"timer-header-edit": "Edit timer",
|
||||
"timer-name": "Timer name",
|
||||
"timer-name-placeholder": "my-timer",
|
||||
"timer-action-new": "Create",
|
||||
"timer-action-edit": "Edit",
|
||||
"remove-timer-title": "Remove timer {{name}}?",
|
||||
"timer-parameters": "every {{time}}, ≥ {{messages}} messages in the last {{interval}}",
|
||||
"timer-interval": "Minimul interval",
|
||||
"timer-activity": "Minimul chat activity (0 to disable)",
|
||||
"timer-activity-desc": "messages in the last 5 minutes",
|
||||
"timer-messages": "Messages"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Authentication required",
|
||||
"desc": "The installation of strimertül you are trying to reach is protected with a password. Please write the password below to access the control panel.",
|
||||
"no-pwd-note": " If the database has no password (for example, it was recently changed from having one to none), leave the field empty.",
|
||||
"password": "Password",
|
||||
"submit": "Authenticate"
|
||||
}
|
||||
},
|
||||
"form-actions": {
|
||||
|
@ -137,9 +162,18 @@
|
|||
"disable": "Disable",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"ok": "OK"
|
||||
"ok": "OK",
|
||||
"add": "Add"
|
||||
},
|
||||
"debug": {
|
||||
"dev-build": "Development build"
|
||||
},
|
||||
"time": {
|
||||
"x-hours": "{{time}} hrs",
|
||||
"x-minutes": "{{time}} min",
|
||||
"x-seconds": "{{time}} sec",
|
||||
"hours": "hours",
|
||||
"minutes": "minutes",
|
||||
"seconds": "seconds"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
ChatBubbleIcon,
|
||||
|
@ -20,13 +20,27 @@ import ServerSettingsPage from './pages/ServerSettings';
|
|||
import { RootState } from '../store';
|
||||
import { createWSClient } from '../store/api/reducer';
|
||||
import { ConnectionStatus } from '../store/api/types';
|
||||
import { styled } from './theme';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContainer,
|
||||
FlexRow,
|
||||
InputBox,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
styled,
|
||||
TextBlock,
|
||||
} from './theme';
|
||||
|
||||
// @ts-expect-error Asset import
|
||||
import spinner from '../assets/icon-loading.svg';
|
||||
import BackendIntegrationPage from './pages/BackendIntegration';
|
||||
import TwitchSettingsPage from './pages/TwitchSettings';
|
||||
import TwitchBotCommandsPage from './pages/BotCommands';
|
||||
import TwitchBotTimersPage from './pages/BotTimers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DialogContent from './components/DialogContent';
|
||||
import AuthDialog from './pages/AuthDialog';
|
||||
|
||||
const LoadingDiv = styled('div', {
|
||||
display: 'flex',
|
||||
|
@ -46,18 +60,6 @@ function Loading() {
|
|||
</LoadingDiv>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthDialog() {
|
||||
const AuthWrapper = styled('div', {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
});
|
||||
|
||||
return <AuthWrapper></AuthWrapper>;
|
||||
}
|
||||
|
||||
const sections: RouteSection[] = [
|
||||
{
|
||||
title: 'menu.sections.monitor',
|
||||
|
@ -194,6 +196,10 @@ export default function App(): JSX.Element {
|
|||
path="/twitch/bot/commands"
|
||||
element={<TwitchBotCommandsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/twitch/bot/timers"
|
||||
element={<TwitchBotTimersPage />}
|
||||
/>
|
||||
</Routes>
|
||||
</PageWrapper>
|
||||
</PageContent>
|
||||
|
|
88
frontend/src/ui/components/Interval.tsx
Normal file
88
frontend/src/ui/components/Interval.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getInterval } from '../../lib/time-utils';
|
||||
import { ComboBox, FlexRow, InputBox } from '../theme';
|
||||
|
||||
export interface TimeUnit {
|
||||
multiplier: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export const seconds = { multiplier: 1, unit: 'time.seconds' };
|
||||
export const minutes = { multiplier: 60, unit: 'time.minutes' };
|
||||
export const hours = { multiplier: 3600, unit: 'time.hours' };
|
||||
|
||||
export interface IntervalProps {
|
||||
active: boolean;
|
||||
value: number;
|
||||
id?: string;
|
||||
min?: number;
|
||||
units?: TimeUnit[];
|
||||
onChange?: (value: number) => void;
|
||||
}
|
||||
|
||||
function Interval({ id, 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 (
|
||||
<>
|
||||
<FlexRow align="left" border="form">
|
||||
<InputBox
|
||||
id={id}
|
||||
type="number"
|
||||
border="none"
|
||||
css={{
|
||||
maxWidth: '5rem',
|
||||
borderRightWidth: '1px',
|
||||
borderRadius: '$borderRadius$form 0 0 $borderRadius$form',
|
||||
}}
|
||||
value={num ?? ''}
|
||||
onChange={(ev) => {
|
||||
const intNum = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
setNum(intNum);
|
||||
}}
|
||||
placeholder="#"
|
||||
/>
|
||||
<ComboBox
|
||||
border="none"
|
||||
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>
|
||||
))}
|
||||
</ComboBox>
|
||||
</FlexRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(Interval);
|
78
frontend/src/ui/components/MultiInput.tsx
Normal file
78
frontend/src/ui/components/MultiInput.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { Cross2Icon } from '@radix-ui/react-icons';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, FlexRow, InputBox, Textarea } from '../theme';
|
||||
|
||||
export interface MessageArrayProps {
|
||||
placeholder?: string;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
function MessageArray({ value, placeholder, onChange }: MessageArrayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{value.map((message, index) => (
|
||||
<FlexRow key={message + index} css={{ marginTop: '0.5rem', flex: 1 }}>
|
||||
<FlexRow border="form" css={{ flex: 1, alignItems: 'stretch' }}>
|
||||
<Textarea
|
||||
border="none"
|
||||
placeholder={placeholder}
|
||||
onChange={(ev) => {
|
||||
const newMessages = [...value];
|
||||
newMessages[index] = ev.target.value;
|
||||
onChange(newMessages);
|
||||
}}
|
||||
className={message !== '' ? 'input' : 'input is-danger'}
|
||||
css={
|
||||
value.length > 1
|
||||
? {
|
||||
borderRadius: '$borderRadius$form 0 0 $borderRadius$form',
|
||||
flex: 1,
|
||||
}
|
||||
: { flex: 1 }
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</Textarea>
|
||||
{value.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variation="danger"
|
||||
styling="form"
|
||||
onClick={() => {
|
||||
const newMessages = [...value];
|
||||
newMessages.splice(index, 1);
|
||||
onChange(newMessages.length > 0 ? newMessages : ['']);
|
||||
}}
|
||||
css={{
|
||||
margin: '-1px',
|
||||
borderRadius: '0 $borderRadius$form $borderRadius$form 0',
|
||||
}}
|
||||
>
|
||||
<Cross2Icon />
|
||||
</Button>
|
||||
)}
|
||||
</FlexRow>
|
||||
</FlexRow>
|
||||
))}
|
||||
<FlexRow align="left">
|
||||
<Button
|
||||
size="small"
|
||||
styling="link"
|
||||
type="button"
|
||||
css={{ marginTop: '0.5rem' }}
|
||||
onClick={() => {
|
||||
onChange([...value, '']);
|
||||
}}
|
||||
>
|
||||
{t('form-actions.add')}
|
||||
</Button>
|
||||
</FlexRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(MessageArray);
|
|
@ -38,10 +38,12 @@ const Header = styled('div', {
|
|||
const AppName = styled('h1', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.2rem',
|
||||
fontSize: '1.4rem',
|
||||
margin: '0.5rem 0 0.5rem 0',
|
||||
fontWeight: 300,
|
||||
paddingRight: '0.5rem',
|
||||
});
|
||||
|
||||
const VersionLabel = styled('div', {
|
||||
|
@ -49,7 +51,7 @@ const VersionLabel = styled('div', {
|
|||
fontSize: '0.75rem',
|
||||
fontWeight: 'bold',
|
||||
color: '$teal8',
|
||||
paddingLeft: '12px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
const UpdateButton = styled('a', {
|
||||
|
|
80
frontend/src/ui/pages/AuthDialog.tsx
Normal file
80
frontend/src/ui/pages/AuthDialog.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import { styled } from '@stitches/react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, InputBox, TextBlock } from '../theme';
|
||||
|
||||
const AuthWrapper = styled('div', {
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
maxWidth: '600px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '90vw',
|
||||
margin: '0 auto',
|
||||
});
|
||||
const AuthTitle = styled('div', {
|
||||
fontWeight: 'bold',
|
||||
color: '$teal12',
|
||||
fontSize: '15pt',
|
||||
borderBottom: '1px solid $teal6',
|
||||
padding: '1rem 1.5rem',
|
||||
margin: '0',
|
||||
lineHeight: '1.25',
|
||||
});
|
||||
const AuthDialogContainer = styled('form', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '0',
|
||||
backgroundColor: '$gray2',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow:
|
||||
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
});
|
||||
const Content = styled('div', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1.5rem',
|
||||
gap: '1rem',
|
||||
});
|
||||
const Actions = styled('div', {
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'flex-end',
|
||||
borderTop: '1px solid $gray6',
|
||||
padding: '1rem 1.5rem',
|
||||
});
|
||||
|
||||
export default function AuthDialog(): React.ReactElement {
|
||||
const [password, setPassword] = useState('');
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AuthWrapper>
|
||||
<AuthDialogContainer
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
localStorage.setItem('password', password);
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<AuthTitle>{t('pages.auth.title')}</AuthTitle>
|
||||
<Content>
|
||||
<TextBlock spacing="none">{t('pages.auth.desc')}</TextBlock>
|
||||
<TextBlock spacing="none">{t('pages.auth.no-pwd-note')}</TextBlock>
|
||||
</Content>
|
||||
<Actions>
|
||||
<InputBox
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
css={{ flex: 1 }}
|
||||
placeholder={t('pages.auth.password')}
|
||||
/>
|
||||
<Button variation="primary" type="submit">
|
||||
{t('pages.auth.submit')}
|
||||
</Button>
|
||||
</Actions>
|
||||
</AuthDialogContainer>
|
||||
</AuthWrapper>
|
||||
);
|
||||
}
|
|
@ -22,6 +22,7 @@ import {
|
|||
FlexRow,
|
||||
InputBox,
|
||||
Label,
|
||||
MultiButton,
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
|
@ -103,7 +104,7 @@ interface CommandItemProps {
|
|||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function CommandItemEl({
|
||||
function CommandItem({
|
||||
name,
|
||||
item,
|
||||
onToggle,
|
||||
|
@ -126,36 +127,38 @@ function CommandItemEl({
|
|||
{item.access_level !== 'streamer' && '+'}
|
||||
</ACLIndicator>
|
||||
)}
|
||||
<Button
|
||||
styling="link"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
{t(item.enabled ? 'form-actions.disable' : 'form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="link"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
<AlertTrigger asChild>
|
||||
<Button styling="link" size="small">
|
||||
{t('form-actions.delete')}
|
||||
</Button>
|
||||
</AlertTrigger>
|
||||
<AlertContent
|
||||
variation="danger"
|
||||
title={t('pages.botcommands.remove-command-title', { name })}
|
||||
description="This cannot be undone"
|
||||
actionText="Delete"
|
||||
actionButtonProps={{ variation: 'danger' }}
|
||||
showCancel={true}
|
||||
onAction={() => (onDelete ? onDelete() : null)}
|
||||
/>
|
||||
</Alert>
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
{t(item.enabled ? 'form-actions.disable' : 'form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
<AlertTrigger asChild>
|
||||
<Button styling="multi" size="small">
|
||||
{t('form-actions.delete')}
|
||||
</Button>
|
||||
</AlertTrigger>
|
||||
<AlertContent
|
||||
variation="danger"
|
||||
title={t('pages.botcommands.remove-command-title', { name })}
|
||||
description="This cannot be undone"
|
||||
actionText="Delete"
|
||||
actionButtonProps={{ variation: 'danger' }}
|
||||
showCancel={true}
|
||||
onAction={() => (onDelete ? onDelete() : null)}
|
||||
/>
|
||||
</Alert>
|
||||
</MultiButton>
|
||||
</CommandActions>
|
||||
</CommandHeader>
|
||||
<CommandText>{item.response}</CommandText>
|
||||
|
@ -163,8 +166,6 @@ function CommandItemEl({
|
|||
);
|
||||
}
|
||||
|
||||
const CommandItem = React.memo(CommandItemEl);
|
||||
|
||||
type DialogPrompt =
|
||||
| { kind: 'new' }
|
||||
| { kind: 'edit'; name: string; item: TwitchBotCustomCommand };
|
||||
|
@ -269,20 +270,20 @@ function CommandDialog({
|
|||
}
|
||||
|
||||
export default function TwitchBotCommandsPage(): React.ReactElement {
|
||||
const [botCommands, setBotCommands] = useModule(modules.twitchBotCommands);
|
||||
const [commandFilter, setCommandFilter] = useState('');
|
||||
const [commands, setCommands] = useModule(modules.twitchBotCommands);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const commandFilterLC = commandFilter.toLowerCase();
|
||||
const filterLC = filter.toLowerCase();
|
||||
|
||||
const setCommand = (newName: string, data: TwitchBotCustomCommand): void => {
|
||||
switch (activeDialog.kind) {
|
||||
case 'new':
|
||||
dispatch(
|
||||
setBotCommands({
|
||||
...botCommands,
|
||||
setCommands({
|
||||
...commands,
|
||||
[newName]: {
|
||||
...data,
|
||||
enabled: true,
|
||||
|
@ -293,8 +294,8 @@ export default function TwitchBotCommandsPage(): React.ReactElement {
|
|||
case 'edit': {
|
||||
const oldName = activeDialog.name;
|
||||
dispatch(
|
||||
setBotCommands({
|
||||
...botCommands,
|
||||
setCommands({
|
||||
...commands,
|
||||
[oldName]: undefined,
|
||||
[newName]: data,
|
||||
}),
|
||||
|
@ -307,8 +308,8 @@ export default function TwitchBotCommandsPage(): React.ReactElement {
|
|||
|
||||
const deleteCommand = (cmd: string): void => {
|
||||
dispatch(
|
||||
setBotCommands({
|
||||
...botCommands,
|
||||
setCommands({
|
||||
...commands,
|
||||
[cmd]: undefined,
|
||||
}),
|
||||
);
|
||||
|
@ -316,11 +317,11 @@ export default function TwitchBotCommandsPage(): React.ReactElement {
|
|||
|
||||
const toggleCommand = (cmd: string): void => {
|
||||
dispatch(
|
||||
setBotCommands({
|
||||
...botCommands,
|
||||
setCommands({
|
||||
...commands,
|
||||
[cmd]: {
|
||||
...botCommands[cmd],
|
||||
enabled: !botCommands[cmd].enabled,
|
||||
...commands[cmd],
|
||||
enabled: !commands[cmd].enabled,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
@ -344,25 +345,25 @@ export default function TwitchBotCommandsPage(): React.ReactElement {
|
|||
<InputBox
|
||||
css={{ flex: 1 }}
|
||||
placeholder={t('pages.botcommands.search-placeholder')}
|
||||
value={commandFilter}
|
||||
onChange={(e) => setCommandFilter(e.target.value)}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</FlexRow>
|
||||
<CommandList>
|
||||
{Object.keys(botCommands ?? {})
|
||||
?.filter((cmd) => cmd.toLowerCase().includes(commandFilterLC))
|
||||
{Object.keys(commands ?? {})
|
||||
?.filter((cmd) => cmd.toLowerCase().includes(filterLC))
|
||||
.sort()
|
||||
.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd}
|
||||
name={cmd}
|
||||
item={botCommands[cmd]}
|
||||
item={commands[cmd]}
|
||||
onToggle={() => toggleCommand(cmd)}
|
||||
onEdit={() =>
|
||||
setActiveDialog({
|
||||
kind: 'edit',
|
||||
name: cmd,
|
||||
item: botCommands[cmd],
|
||||
item: commands[cmd],
|
||||
})
|
||||
}
|
||||
onDelete={() => deleteCommand(cmd)}
|
||||
|
|
417
frontend/src/ui/pages/BotTimers.tsx
Normal file
417
frontend/src/ui/pages/BotTimers.tsx
Normal file
|
@ -0,0 +1,417 @@
|
|||
import { PlusIcon } from '@radix-ui/react-icons';
|
||||
import React, { useState } from 'react';
|
||||
import { TFunction, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModule } from '../../lib/react-utils';
|
||||
import { modules } from '../../store/api/reducer';
|
||||
import { TwitchBotTimer } from '../../store/api/types';
|
||||
import AlertContent from '../components/AlertContent';
|
||||
import DialogContent from '../components/DialogContent';
|
||||
import Interval, { hours, minutes } from '../components/Interval';
|
||||
import MultiInput from '../components/MultiInput';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogClose,
|
||||
Field,
|
||||
FlexRow,
|
||||
InputBox,
|
||||
Label,
|
||||
MultiButton,
|
||||
PageContainer,
|
||||
PageHeader,
|
||||
PageTitle,
|
||||
styled,
|
||||
TextBlock,
|
||||
} from '../theme';
|
||||
import { Alert, AlertTrigger } from '../theme/alert';
|
||||
|
||||
const TimerList = styled('div', { marginTop: '1rem' });
|
||||
const TimerItemContainer = styled('article', {
|
||||
backgroundColor: '$gray2',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
borderLeft: '5px solid $teal8',
|
||||
borderRadius: '0.25rem',
|
||||
borderBottom: '1px solid $gray4',
|
||||
transition: 'all 50ms',
|
||||
'&:hover': {
|
||||
backgroundColor: '$gray3',
|
||||
},
|
||||
variants: {
|
||||
status: {
|
||||
enabled: {},
|
||||
disabled: {
|
||||
borderLeftColor: '$red7',
|
||||
backgroundColor: '$gray3',
|
||||
color: '$gray10',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const TimerHeader = styled('header', {
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
marginBottom: '0.4rem',
|
||||
});
|
||||
const TimerName = styled('span', {
|
||||
color: '$teal10',
|
||||
fontWeight: 'bold',
|
||||
variants: {
|
||||
status: {
|
||||
enabled: {},
|
||||
disabled: {
|
||||
color: '$gray10',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const TimerDescription = styled('span', {
|
||||
flex: 1,
|
||||
});
|
||||
const TimerActions = styled('div', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
});
|
||||
const TimerText = styled('div', {
|
||||
fontFamily: 'Space Mono',
|
||||
fontSize: '10pt',
|
||||
margin: '0 -0.5rem',
|
||||
marginTop: '0',
|
||||
marginBottom: '0.3rem',
|
||||
padding: '0.5rem',
|
||||
backgroundColor: '$gray4',
|
||||
lineHeight: '1.2rem',
|
||||
'&:last-child': {
|
||||
marginBottom: '-0.5rem',
|
||||
},
|
||||
});
|
||||
|
||||
function humanTime(t: TFunction<'translation'>, secs: number): string {
|
||||
const mins = Math.floor(secs / 60);
|
||||
const hrs = Math.floor(mins / 60);
|
||||
|
||||
if (hrs > 0) {
|
||||
return t('time.x-hours', { time: hrs });
|
||||
}
|
||||
if (mins > 0) {
|
||||
return t('time.x-minutes', { time: mins });
|
||||
}
|
||||
return t('time.x-seconds', { time: secs });
|
||||
}
|
||||
|
||||
interface TimerItemProps {
|
||||
name: string;
|
||||
item: TwitchBotTimer;
|
||||
onToggle?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function TimerItem({
|
||||
name,
|
||||
item,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TimerItemProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TimerItemContainer status={item.enabled ? 'enabled' : 'disabled'}>
|
||||
<TimerHeader>
|
||||
<TimerName status={item.enabled ? 'enabled' : 'disabled'}>
|
||||
{name}
|
||||
</TimerName>
|
||||
<TimerDescription>
|
||||
(
|
||||
{t('pages.bottimers.timer-parameters', {
|
||||
time: humanTime(t, item.minimum_delay),
|
||||
messages: item.minimum_chat_activity,
|
||||
interval: humanTime(t, 300),
|
||||
})}
|
||||
)
|
||||
</TimerDescription>
|
||||
<TimerActions>
|
||||
<MultiButton>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onToggle ? onToggle() : null)}
|
||||
>
|
||||
{t(item.enabled ? 'form-actions.disable' : 'form-actions.enable')}
|
||||
</Button>
|
||||
<Button
|
||||
styling="multi"
|
||||
size="small"
|
||||
onClick={() => (onEdit ? onEdit() : null)}
|
||||
>
|
||||
{t('form-actions.edit')}
|
||||
</Button>
|
||||
<Alert>
|
||||
<AlertTrigger asChild>
|
||||
<Button styling="multi" size="small">
|
||||
{t('form-actions.delete')}
|
||||
</Button>
|
||||
</AlertTrigger>
|
||||
<AlertContent
|
||||
variation="danger"
|
||||
title={t('pages.bottimers.remove-timer-title', { name })}
|
||||
description="This cannot be undone"
|
||||
actionText="Delete"
|
||||
actionButtonProps={{ variation: 'danger' }}
|
||||
showCancel={true}
|
||||
onAction={() => (onDelete ? onDelete() : null)}
|
||||
/>
|
||||
</Alert>
|
||||
</MultiButton>
|
||||
</TimerActions>
|
||||
</TimerHeader>
|
||||
{item.messages?.map((message, index) => (
|
||||
<TimerText key={index}>{message}</TimerText>
|
||||
))}
|
||||
</TimerItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogPrompt =
|
||||
| { kind: 'new' }
|
||||
| { kind: 'edit'; name: string; item: TwitchBotTimer };
|
||||
|
||||
function TimerDialog({
|
||||
kind,
|
||||
name,
|
||||
item,
|
||||
onSubmit,
|
||||
}: {
|
||||
kind: 'new' | 'edit';
|
||||
name?: string;
|
||||
item?: TwitchBotTimer;
|
||||
onSubmit?: (name: string, item: TwitchBotTimer) => void;
|
||||
}) {
|
||||
const [timerName, setName] = useState(name ?? '');
|
||||
const [messages, setMessages] = useState(item?.messages ?? ['']);
|
||||
const [minDelay, setMinDelay] = useState(item?.minimum_delay ?? 300);
|
||||
const [minActivity, setMinActivity] = useState(
|
||||
item?.minimum_chat_activity ?? 5,
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DialogContent title={t(`pages.bottimers.timer-header-${kind}`)}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (onSubmit) {
|
||||
onSubmit(timerName, {
|
||||
...item,
|
||||
messages,
|
||||
minimum_delay: minDelay,
|
||||
minimum_chat_activity: minActivity,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="timer-name">{t('pages.bottimers.timer-name')}</Label>
|
||||
<InputBox
|
||||
id="timer-name"
|
||||
value={timerName}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('pages.bottimers.timer-name-placeholder')}
|
||||
/>
|
||||
</Field>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="timer-interval">
|
||||
{t('pages.bottimers.timer-interval')}
|
||||
</Label>
|
||||
<FlexRow align="left">
|
||||
<Interval
|
||||
id="timer-interval"
|
||||
value={minDelay}
|
||||
onChange={setMinDelay}
|
||||
active={true}
|
||||
min={60}
|
||||
units={[minutes, hours]}
|
||||
/>
|
||||
</FlexRow>
|
||||
</Field>
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label htmlFor="timer-activity">
|
||||
{t('pages.bottimers.timer-activity')}
|
||||
</Label>
|
||||
<FlexRow align="left" spacing={1}>
|
||||
<InputBox
|
||||
id="timer-activity"
|
||||
value={minActivity}
|
||||
type="number"
|
||||
css={{
|
||||
width: '5rem',
|
||||
}}
|
||||
onChange={(ev) => {
|
||||
const intNum = parseInt(ev.target.value, 10);
|
||||
if (Number.isNaN(intNum)) {
|
||||
return;
|
||||
}
|
||||
setMinActivity(intNum);
|
||||
}}
|
||||
placeholder="#"
|
||||
/>
|
||||
<span>{t('pages.bottimers.timer-activity-desc')}</span>
|
||||
</FlexRow>
|
||||
</Field>
|
||||
|
||||
<Field spacing="narrow" size="fullWidth">
|
||||
<Label>{t('pages.bottimers.timer-messages')}</Label>
|
||||
<MultiInput value={messages} onChange={setMessages} />
|
||||
</Field>
|
||||
|
||||
<DialogActions>
|
||||
<Button variation="primary">
|
||||
{t(`pages.bottimers.timer-action-${kind}`)}
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button">{t('form-actions.cancel')}</Button>
|
||||
</DialogClose>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TwitchBotTimersPage(): React.ReactElement {
|
||||
const [timerConfig, setTimerConfig] = useModule(modules.twitchBotTimers);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const filterLC = filter.toLowerCase();
|
||||
|
||||
const setTimer = (newName: string, data: TwitchBotTimer): void => {
|
||||
switch (activeDialog.kind) {
|
||||
case 'new':
|
||||
dispatch(
|
||||
setTimerConfig({
|
||||
...timerConfig,
|
||||
timers: {
|
||||
...timerConfig.timers,
|
||||
[newName]: {
|
||||
...data,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'edit': {
|
||||
const oldName = activeDialog.name;
|
||||
dispatch(
|
||||
setTimerConfig({
|
||||
...timerConfig,
|
||||
timers: {
|
||||
...timerConfig.timers,
|
||||
[oldName]: undefined,
|
||||
[newName]: data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
setActiveDialog(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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>{t('pages.bottimers.title')}</PageTitle>
|
||||
<TextBlock>{t('pages.bottimers.desc')}</TextBlock>
|
||||
</PageHeader>
|
||||
|
||||
<FlexRow spacing="1" align="left">
|
||||
<Button
|
||||
variation="primary"
|
||||
onClick={() => setActiveDialog({ kind: 'new' })}
|
||||
>
|
||||
<PlusIcon /> {t('pages.bottimers.add-button')}
|
||||
</Button>
|
||||
|
||||
<InputBox
|
||||
css={{ flex: 1 }}
|
||||
placeholder={t('pages.bottimers.search-placeholder')}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</FlexRow>
|
||||
<TimerList>
|
||||
{Object.keys(timerConfig?.timers ?? {})
|
||||
?.filter((cmd) => cmd.toLowerCase().includes(filterLC))
|
||||
.sort()
|
||||
.map((cmd) => (
|
||||
<TimerItem
|
||||
key={cmd}
|
||||
name={cmd}
|
||||
item={timerConfig.timers[cmd]}
|
||||
onToggle={() => toggleTimer(cmd)}
|
||||
onEdit={() =>
|
||||
setActiveDialog({
|
||||
kind: 'edit',
|
||||
name: cmd,
|
||||
item: timerConfig.timers[cmd],
|
||||
})
|
||||
}
|
||||
onDelete={() => deleteTimer(cmd)}
|
||||
/>
|
||||
))}
|
||||
</TimerList>
|
||||
|
||||
<Dialog
|
||||
open={!!activeDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Reset dialog status on dialog close
|
||||
setActiveDialog(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{activeDialog && (
|
||||
<TimerDialog
|
||||
{...activeDialog}
|
||||
onSubmit={(name, data) => setTimer(name, data)}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import * as UnstyledLabel from '@radix-ui/react-label';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { styled } from './theme';
|
||||
import { theme } from '.';
|
||||
|
||||
export const Field = styled('fieldset', {
|
||||
all: 'unset',
|
||||
|
@ -45,7 +46,7 @@ export const InputBox = styled('input', {
|
|||
fontWeight: '300',
|
||||
border: '1px solid $gray6',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.3rem',
|
||||
borderRadius: theme.borderRadius.form,
|
||||
backgroundColor: '$gray2',
|
||||
'&:hover': {
|
||||
borderColor: '$teal7',
|
||||
|
@ -59,6 +60,13 @@ export const InputBox = styled('input', {
|
|||
borderColor: '$gray5',
|
||||
color: '$gray8',
|
||||
},
|
||||
variants: {
|
||||
border: {
|
||||
none: {
|
||||
borderWidth: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Textarea = styled('textarea', {
|
||||
|
@ -66,7 +74,7 @@ export const Textarea = styled('textarea', {
|
|||
fontWeight: '300',
|
||||
border: '1px solid $gray6',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.3rem',
|
||||
borderRadius: theme.borderRadius.form,
|
||||
backgroundColor: '$gray2',
|
||||
'&:hover': {
|
||||
borderColor: '$teal7',
|
||||
|
@ -80,6 +88,13 @@ export const Textarea = styled('textarea', {
|
|||
borderColor: '$gray5',
|
||||
color: '$gray8',
|
||||
},
|
||||
variants: {
|
||||
border: {
|
||||
none: {
|
||||
borderWidth: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ButtonGroup = styled('div', {
|
||||
|
@ -87,12 +102,17 @@ export const ButtonGroup = styled('div', {
|
|||
gap: '0.5rem',
|
||||
});
|
||||
|
||||
export const MultiButton = styled('div', {
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const Button = styled('button', {
|
||||
all: 'unset',
|
||||
cursor: 'pointer',
|
||||
color: '$gray12',
|
||||
fontWeight: '300',
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.3rem',
|
||||
borderRadius: theme.borderRadius.form,
|
||||
fontSize: '1.1rem',
|
||||
border: '1px solid $gray6',
|
||||
backgroundColor: '$gray4',
|
||||
|
@ -108,13 +128,34 @@ export const Button = styled('button', {
|
|||
},
|
||||
transition: 'all 0.2s',
|
||||
variants: {
|
||||
border: {
|
||||
none: {
|
||||
borderWidth: '0',
|
||||
},
|
||||
},
|
||||
styling: {
|
||||
form: {
|
||||
padding: '0.65rem',
|
||||
},
|
||||
link: {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '$teal11',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
multi: {
|
||||
borderRadius: '0',
|
||||
margin: '0 -1px',
|
||||
'&:first-child': {
|
||||
borderRadius: `$borderRadius$form 0 0 $borderRadius$form`,
|
||||
},
|
||||
'&:last-child': {
|
||||
borderRadius: `0 $borderRadius$form $borderRadius$form 0`,
|
||||
},
|
||||
'&:hover': {
|
||||
zIndex: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
size: {
|
||||
small: {
|
||||
|
@ -177,7 +218,7 @@ export const ComboBox = styled('select', {
|
|||
fontWeight: '300',
|
||||
border: '1px solid $gray6',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.3rem',
|
||||
borderRadius: theme.borderRadius.form,
|
||||
backgroundColor: '$gray2',
|
||||
'&:hover': {
|
||||
borderColor: '$teal7',
|
||||
|
@ -191,6 +232,13 @@ export const ComboBox = styled('select', {
|
|||
borderColor: '$gray5',
|
||||
color: '$gray8',
|
||||
},
|
||||
variants: {
|
||||
border: {
|
||||
none: {
|
||||
borderWidth: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const Checkbox = styled(CheckboxPrimitive.Root, {
|
||||
|
|
|
@ -31,4 +31,11 @@ export const SectionHeader = styled('h2', {
|
|||
|
||||
export const TextBlock = styled('p', {
|
||||
lineHeight: '1.5',
|
||||
variants: {
|
||||
spacing: {
|
||||
none: {
|
||||
margin: '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -37,5 +37,8 @@ export const { styled, theme } = createStitches({
|
|||
...redDark,
|
||||
...blackA,
|
||||
},
|
||||
borderRadius: {
|
||||
form: '0.3rem',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { theme } from '.';
|
||||
import { styled } from './theme';
|
||||
|
||||
export const FlexRow = styled('div', {
|
||||
|
@ -6,6 +7,12 @@ export const FlexRow = styled('div', {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
variants: {
|
||||
border: {
|
||||
form: {
|
||||
border: '1px solid $gray6',
|
||||
borderRadius: theme.borderRadius.form,
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
'1': {
|
||||
gap: '0.5rem',
|
||||
|
|
Loading…
Reference in a new issue