strimertul/frontend/src/ui/pages/ChatTimers.tsx

448 lines
12 KiB
TypeScript

import { PlusIcon } from '@radix-ui/react-icons';
import { TFunction } from 'i18next';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { TwitchChatTimer } from '~/store/api/types';
import AlertContent from '../components/AlertContent';
import DialogContent from '../components/DialogContent';
import Interval from '../components/forms/Interval';
import { hours, minutes } from '../components/forms/units';
import MultiInput from '../components/forms/MultiInput';
import {
Button,
Dialog,
DialogActions,
DialogClose,
Field,
FlexRow,
InputBox,
Label,
MultiButton,
NoneText,
PageContainer,
PageHeader,
PageTitle,
styled,
TextBlock,
} from '../theme';
import { Alert, AlertTrigger } from '../theme/alert';
const TimerList = styled('div', { marginTop: '1rem' });
const TimerItemContainer = styled('article', {
backgroundColor: '$gray3',
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: TwitchChatTimer;
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={t('form-actions.warning-delete')}
actionText={t('form-actions.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: TwitchChatTimer };
function TimerDialog({
kind,
name,
item,
onSubmit,
}: {
kind: 'new' | 'edit';
name?: string;
item?: TwitchChatTimer;
onSubmit?: (name: string, item: TwitchChatTimer) => void;
}) {
const [timerConfig] = useModule(modules.twitchChatTimers);
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}`)}
closeButton={true}
>
<form
onSubmit={(e) => {
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
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);
// If timer name is different but matches another defined timer, set as invalid
if (
e.target.value !== name &&
e.target.value in timerConfig.timers
) {
(e.target as HTMLInputElement).setCustomValidity(
t('pages.bottimers.name-already-in-use'),
);
} else {
(e.target as HTMLInputElement).setCustomValidity('');
}
}}
placeholder={t('pages.bottimers.timer-name-placeholder')}
required={true}
/>
</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]}
required={true}
/>
</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"
defaultValue={minActivity}
type="number"
css={{
width: '5rem',
}}
required={true}
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 required={true} value={messages} onChange={setMessages} />
</Field>
<DialogActions>
<Button variation="primary">
{kind === 'new' ? t('form-actions.create') : t('form-actions.edit')}
</Button>
<DialogClose asChild>
<Button type="button">{t('form-actions.cancel')}</Button>
</DialogClose>
</DialogActions>
</form>
</DialogContent>
);
}
export default function TwitchChatTimersPage(): React.ReactElement {
const [timerConfig, setTimerConfig] = useModule(modules.twitchChatTimers);
const [filter, setFilter] = useState('');
const [activeDialog, setActiveDialog] = useState<DialogPrompt>(null);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const filterLC = filter.toLowerCase();
const setTimer = (newName: string, data: TwitchChatTimer): void => {
switch (activeDialog.kind) {
case 'new':
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[newName]: {
...data,
enabled: true,
},
},
}),
);
break;
case 'edit': {
const oldName = activeDialog.name;
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[oldName]: undefined,
[newName]: data,
},
}),
);
break;
}
}
setActiveDialog(null);
};
const deleteTimer = (cmd: string): void => {
void dispatch(
setTimerConfig({
...timerConfig,
timers: {
...timerConfig.timers,
[cmd]: undefined,
},
}),
);
};
const toggleTimer = (cmd: string): void => {
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>
{timerConfig?.timers ? (
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)}
/>
))
) : (
<NoneText>{t('pages.bottimers.no-timers')}</NoneText>
)}
</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>
);
}