1
0
Fork 0
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:
Ash Keel 2022-01-10 11:19:54 +01:00
parent 64366a7903
commit 0b7f7fe069
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
14 changed files with 843 additions and 86 deletions

View file

@ -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",

View file

@ -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",

View file

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

View file

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

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

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

View file

@ -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', {

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

View file

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

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

View file

@ -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, {

View file

@ -31,4 +31,11 @@ export const SectionHeader = styled('h2', {
export const TextBlock = styled('p', {
lineHeight: '1.5',
variants: {
spacing: {
none: {
margin: '0',
},
},
},
});

View file

@ -37,5 +37,8 @@ export const { styled, theme } = createStitches({
...redDark,
...blackA,
},
borderRadius: {
form: '0.3rem',
},
},
});

View file

@ -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',