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

Re-add pagelist and WIP LoyaltyQueue

This commit is contained in:
Ash Keel 2022-01-12 13:20:30 +01:00
parent 7e65c72bbb
commit dd15e65251
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
12 changed files with 591 additions and 105 deletions

View file

@ -750,6 +750,26 @@
}
}
},
"@radix-ui/react-separator": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-0.1.3.tgz",
"integrity": "sha512-4T19/TwF/fp/5x8H2KjNre1htv5iJkXcXzr4QJnqEYiE/wkPwWlW/vcuTWKIY8AXDp+tAgGw0UPrajV7loU+TA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-primitive": "0.1.3"
},
"dependencies": {
"@radix-ui/react-primitive": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.3.tgz",
"integrity": "sha512-fcyADaaAx2jdqEDLsTs6aX50S3L1c9K9CC6XMpJpuXFJCU4n9PGTFDZRtY2gAoXXoRCPIBsklCopSmGb6SsDjQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "0.1.2"
}
}
}
},
"@radix-ui/react-slot": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz",
@ -794,6 +814,78 @@
}
}
},
"@radix-ui/react-toggle": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-0.1.3.tgz",
"integrity": "sha512-ig2LsivpVOxbPoBpNy/6dgB5WNm0mkPMaBBgwo7yufDhlUExdp+bRCqkcWkN9EV42SEbd4etNEIrLAqppfgq3g==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-use-controllable-state": "0.1.0"
},
"dependencies": {
"@radix-ui/react-primitive": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.3.tgz",
"integrity": "sha512-fcyADaaAx2jdqEDLsTs6aX50S3L1c9K9CC6XMpJpuXFJCU4n9PGTFDZRtY2gAoXXoRCPIBsklCopSmGb6SsDjQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "0.1.2"
}
}
}
},
"@radix-ui/react-toggle-group": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-0.1.4.tgz",
"integrity": "sha512-pzs3Cc5I9JKnBdCBCB4xfhbQpWNKY9dYwKVxcYLzGmaUsVPUiAq2AYr/Ht3LebhIxWYHkggjX8EOTeO6ExMr8Q==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-roving-focus": "0.1.4",
"@radix-ui/react-toggle": "0.1.3",
"@radix-ui/react-use-controllable-state": "0.1.0"
},
"dependencies": {
"@radix-ui/react-primitive": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.3.tgz",
"integrity": "sha512-fcyADaaAx2jdqEDLsTs6aX50S3L1c9K9CC6XMpJpuXFJCU4n9PGTFDZRtY2gAoXXoRCPIBsklCopSmGb6SsDjQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "0.1.2"
}
}
}
},
"@radix-ui/react-toolbar": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-0.1.4.tgz",
"integrity": "sha512-0+Qoq/dkQCwNGvOXAopWDz1HGR85HdkFrNGK2WOAQ5goktgeqRgd0QBixOOsLP+vorBBMZSGxkNocSZpZg8yoA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-primitive": "0.1.3",
"@radix-ui/react-roving-focus": "0.1.4",
"@radix-ui/react-separator": "0.1.3",
"@radix-ui/react-toggle-group": "0.1.4"
},
"dependencies": {
"@radix-ui/react-primitive": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.3.tgz",
"integrity": "sha512-fcyADaaAx2jdqEDLsTs6aX50S3L1c9K9CC6XMpJpuXFJCU4n9PGTFDZRtY2gAoXXoRCPIBsklCopSmGb6SsDjQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "0.1.2"
}
}
}
},
"@radix-ui/react-use-body-pointer-events": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.1.0.tgz",

View file

@ -11,6 +11,7 @@
"@radix-ui/react-icons": "^1.0.3",
"@radix-ui/react-label": "^0.1.3",
"@radix-ui/react-tabs": "^0.1.4",
"@radix-ui/react-toolbar": "^0.1.4",
"@reduxjs/toolkit": "^1.5.1",
"@stitches/react": "^1.2.6",
"@strimertul/kilovolt-client": "^6.2.0",

View file

@ -174,6 +174,12 @@
"note": "Note: Unlike platform-native systems (eg. Twitch channel points), this relies on chat activity rather than actual viewing status.",
"every": "every",
"reward": "How often to give {{currency}}"
},
"loyalty-queue": {
"title": "Points and redeems",
"subtitle": "User leaderboard and pending reward redeems",
"queue-tab": "Redeem queue",
"users-tab": "Manage points"
}
},
"form-actions": {
@ -199,5 +205,15 @@
"hours": "hours",
"minutes": "minutes",
"seconds": "seconds"
},
"pagination": {
"items-per-page": "Items per page",
"page": "Page {{page}}",
"gotopage": "Go to page {{page}}",
"title": "pagination",
"previous": "Previous page",
"next": "Next page",
"gotolast": "Go to last page",
"gotofirst": "Go to first page"
}
}

View file

@ -31,6 +31,7 @@ import TwitchBotTimersPage from './pages/BotTimers';
import AuthDialog from './pages/AuthDialog';
import ChatAlertsPage from './pages/ChatAlerts';
import LoyaltyConfigPage from './pages/LoyaltyConfig';
import LoyaltyQueuePage from './pages/LoyaltyQueue';
const LoadingDiv = styled('div', {
display: 'flex',
@ -192,6 +193,7 @@ export default function App(): JSX.Element {
/>
<Route path="/twitch/bot/alerts" element={<ChatAlertsPage />} />
<Route path="/loyalty/settings" element={<LoyaltyConfigPage />} />
<Route path="/loyalty/users" element={<LoyaltyQueuePage />} />
</Routes>
</PageWrapper>
</PageContent>

View file

@ -57,6 +57,7 @@ function Interval({
type="number"
border="none"
required={required}
disabled={!active}
css={{
maxWidth: '5rem',
borderRightWidth: '1px',

View file

@ -0,0 +1,167 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { styled, Toolbar, ToolbarButton, ToolbarComboBox } from '../theme';
export interface PageListProps {
current: number;
max: number;
min: number;
itemsPerPage: number;
onSelectChange: (itemsPerPage: number) => void;
onPageChange: (page: number) => void;
}
const ToolbarSection = styled('section', {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.3rem',
});
function PageList({
current,
max,
min,
itemsPerPage,
onSelectChange,
onPageChange,
}: PageListProps): React.ReactElement {
const { t } = useTranslation();
return (
<Toolbar
role="navigation"
aria-label={t('pagination.title')}
css={{
'@medium': {
flexDirection: 'row-reverse',
justifyContent: 'space-between',
},
}}
>
<ToolbarSection
css={{
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
>
<ToolbarButton
aria-label={t('pagination.previous')}
title={t('pagination.previous')}
disabled={current <= min}
onClick={() => onPageChange(current - 1)}
css={{
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
>
&lsaquo;
</ToolbarButton>
<ToolbarButton
aria-label={t('pagination.next')}
title={t('pagination.next')}
disabled={current >= max}
onClick={() => onPageChange(current + 1)}
css={{
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
>
&rsaquo;
</ToolbarButton>
<ToolbarComboBox
title={t('pagination.items-per-page')}
aria-label={t('pagination.items-per-page')}
value={itemsPerPage}
onChange={(ev) => onSelectChange(Number(ev.target.value))}
css={{
textAlign: 'center',
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
>
<option value={15}>15</option>
<option value={30}>30</option>
<option value={50}>50</option>
<option value={100}>100</option>
</ToolbarComboBox>
</ToolbarSection>
<ToolbarSection>
{current > min ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotofirst')}
title={t('pagination.gotofirst')}
onClick={() => onPageChange(min)}
>
{min}
</ToolbarButton>
) : null}
{current > min + 2 ? (
<span className="pagination-ellipsis">&hellip;</span>
) : null}
{current > min + 1 ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotopage', {
page: current - 1,
})}
title={t('pagination.gotopage', {
page: current - 1,
})}
onClick={() => onPageChange(current - 1)}
>
{current - 1}
</ToolbarButton>
) : null}
<ToolbarButton
disabled={true}
className="pagination-link is-current"
aria-label={t('pagination.page', {
page: current,
})}
title={t('pagination.page', {
page: current,
})}
aria-current="page"
css={{
border: '1px solid $teal7',
cursor: 'inherit',
background: '$teal7',
}}
>
{current}
</ToolbarButton>
{current < max ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotopage', {
page: current + 1,
})}
title={t('pagination.gotopage', {
page: current + 1,
})}
onClick={() => onPageChange(current + 1)}
>
{current + 1}
</ToolbarButton>
) : null}
{current < max - 2 ? (
<span className="pagination-ellipsis">&hellip;</span>
) : null}
{current < max - 1 ? (
<ToolbarButton
className="button pagination-link"
aria-label={t('pagination.gotolast')}
title={t('pagination.gotolast')}
onClick={() => onPageChange(max)}
>
{max}
</ToolbarButton>
) : null}
</ToolbarSection>
</Toolbar>
);
}
export default React.memo(PageList);

View file

@ -48,7 +48,7 @@ const CommandItemContainer = styled('article', {
status: {
enabled: {},
disabled: {
borderLeftColor: '$red7',
borderLeftColor: '$red6',
backgroundColor: '$gray3',
color: '$gray10',
},
@ -68,7 +68,7 @@ const CommandName = styled('span', {
status: {
enabled: {},
disabled: {
color: '$gray10',
color: '$gray9',
},
},
},

View file

@ -56,106 +56,56 @@ export default function LoyaltySettingsPage(): React.ReactElement {
</FlexRow>
</Field>
</PageHeader>
{active && (
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
<form
onSubmit={(e) => {
e.preventDefault();
if (!(e.target as HTMLFormElement).checkValidity()) {
return;
}
dispatch(setConfig(config));
}}
>
<Field size="fullWidth">
<Label htmlFor="currency">
{t('pages.loyalty-settings.currency-name')}
</Label>
<InputBox
type="text"
id="currency"
placeholder={t('pages.loyalty-settings.currency-placeholder')}
value={config?.currency ?? ''}
disabled={!active || busy}
required={true}
onChange={(e) =>
dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
currency: e.target.value,
}),
)
}
dispatch(setConfig(config));
}}
>
<Field size="fullWidth">
<Label htmlFor="currency">
{t('pages.loyalty-settings.currency-name')}
</Label>
<InputBox
type="text"
id="currency"
placeholder={t('pages.loyalty-settings.currency-placeholder')}
value={config?.currency ?? ''}
disabled={busy}
required={true}
onChange={(e) =>
dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
currency: e.target.value,
}),
)
}
/>
<FieldNote>
{t('pages.loyalty-settings.currency-name-hint')}
</FieldNote>
</Field>
/>
<FieldNote>
{t('pages.loyalty-settings.currency-name-hint')}
</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="reward">
{t('pages.loyalty-settings.reward', {
currency:
config?.currency ??
t('pages.loyalty-settings.currency-placeholder'),
})}
</Label>
<FlexRow align="left" spacing={1}>
<InputBox
type="number"
id="reward"
placeholder={'0'}
css={{ maxWidth: '5rem' }}
value={config?.points?.amount ?? '0'}
disabled={busy}
required={true}
onChange={(e) => {
const intNum = parseInt(e.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
points: {
...config.points,
amount: intNum,
},
}),
);
}}
/>
<div>{t('pages.loyalty-settings.every')}</div>
<Interval
id="timer-interval"
value={config?.points?.interval ?? 120}
onChange={(interval) => {
dispatch(
apiReducer.actions.loyaltyConfigChanged({
...(config ?? {}),
points: {
...config?.points,
interval,
},
}),
);
}}
active={!busy}
min={5}
required={true}
/>
</FlexRow>
</Field>
<Field size="fullWidth">
<Label htmlFor="bonus">
{t('pages.loyalty-settings.bonus-points')}
</Label>
<Field size="fullWidth">
<Label htmlFor="reward">
{t('pages.loyalty-settings.reward', {
currency:
config?.currency ??
t('pages.loyalty-settings.currency-placeholder'),
})}
</Label>
<FlexRow align="left" spacing={1}>
<InputBox
type="number"
id="bonus"
id="reward"
placeholder={'0'}
value={config?.points?.activity_bonus ?? '0'}
disabled={busy}
css={{ maxWidth: '5rem' }}
value={config?.points?.amount ?? '0'}
disabled={!active || busy}
required={true}
onChange={(e) => {
const intNum = parseInt(e.target.value, 10);
@ -167,20 +117,66 @@ export default function LoyaltySettingsPage(): React.ReactElement {
...config,
points: {
...config.points,
activity_bonus: intNum,
amount: intNum,
},
}),
);
}}
/>
<FieldNote>
{t('pages.loyalty-settings.bonus-points-hint')}
</FieldNote>
</Field>
<div>{t('pages.loyalty-settings.every')}</div>
<Interval
id="timer-interval"
value={config?.points?.interval ?? 120}
onChange={(interval) => {
dispatch(
apiReducer.actions.loyaltyConfigChanged({
...(config ?? {}),
points: {
...config?.points,
interval,
},
}),
);
}}
active={active && !busy}
min={5}
required={true}
/>
</FlexRow>
</Field>
<SaveButton type="submit" status={status} />
</form>
)}
<Field size="fullWidth">
<Label htmlFor="bonus">
{t('pages.loyalty-settings.bonus-points')}
</Label>
<InputBox
type="number"
id="bonus"
placeholder={'0'}
value={config?.points?.activity_bonus ?? '0'}
disabled={!active || busy}
required={true}
onChange={(e) => {
const intNum = parseInt(e.target.value, 10);
if (Number.isNaN(intNum)) {
return;
}
dispatch(
apiReducer.actions.loyaltyConfigChanged({
...config,
points: {
...config.points,
activity_bonus: intNum,
},
}),
);
}}
/>
<FieldNote>{t('pages.loyalty-settings.bonus-points-hint')}</FieldNote>
</Field>
<SaveButton type="submit" status={status} />
</form>
</PageContainer>
);
}

View file

@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useModule, useUserPoints } from '../../lib/react-utils';
import { modules } from '../../store/api/reducer';
import PageList from '../components/PageList';
import {
PageContainer,
PageHeader,
PageTitle,
TabButton,
TabContainer,
TabContent,
TabList,
TextBlock,
} from '../theme';
interface UserSortingOrder {
key: 'user' | 'points';
order: 'asc' | 'desc';
}
function RewardQueue() {
const { t } = useTranslation();
const [queue, setQueue] = useModule(modules.loyaltyRedeemQueue);
const dispatch = useDispatch();
// Big hack but this is required or refunds break
useUserPoints();
return <></>;
}
function UserList() {
const { t } = useTranslation();
const users = useUserPoints();
const dispatch = useDispatch();
const [entriesPerPage, setEntriesPerPage] = useState(15);
const [page, setPage] = useState(0);
const [usernameFilter, setUsernameFilter] = useState('');
const [sorting, setSorting] = useState<UserSortingOrder>({
key: 'points',
order: 'desc',
});
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);
return (
<>
<PageList
current={page + 1}
min={1}
max={totalPages + 1}
itemsPerPage={entriesPerPage}
onSelectChange={(em) => setEntriesPerPage(em)}
onPageChange={(p) => setPage(p - 1)}
/>
{paged.map(([user, { points }]) => (
<article key={user}>
{user} - {points}
</article>
))}
<PageList
current={page + 1}
min={1}
max={totalPages + 1}
itemsPerPage={entriesPerPage}
onSelectChange={(em) => setEntriesPerPage(em)}
onPageChange={(p) => setPage(p - 1)}
/>
</>
);
}
export default function LoyaltyQueuePage(): React.ReactElement {
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.loyalty-queue.title')}</PageTitle>
<TextBlock>{t('pages.loyalty-queue.subtitle')}</TextBlock>
</PageHeader>
<TabContainer defaultValue="users">
<TabList>
<TabButton value="queue">
{t('pages.loyalty-queue.queue-tab')}
</TabButton>
<TabButton value="users">
{t('pages.loyalty-queue.users-tab')}
</TabButton>
</TabList>
<TabContent value="queue">
<RewardQueue />
</TabContent>
<TabContent value="users">
<UserList />
</TabContent>
</TabContainer>
</PageContainer>
);
}

View file

@ -4,4 +4,5 @@ export * from './forms';
export * from './pages';
export * from './tabs';
export * from './theme';
export * from './toolbar';
export * from './utils';

View file

@ -39,6 +39,12 @@ export const { styled, theme } = createStitches({
},
borderRadius: {
form: '0.3rem',
toolbar: '0.5rem',
},
},
media: {
mobile: '(min-width: 640px)',
medium: '(min-width: 768px)',
wide: '(min-width: 1024px)',
},
});

View file

@ -0,0 +1,59 @@
import * as ToolbarPrimitive from '@radix-ui/react-toolbar';
import { styled, theme } from './theme';
export const Toolbar = styled(ToolbarPrimitive.Root, {
display: 'flex',
padding: '0.4rem',
margin: '0.5rem 0',
width: '100%',
minWidth: 'max-content',
borderRadius: theme.borderRadius.toolbar,
backgroundColor: '$gray2',
alignItems: 'center',
});
const itemStyles = {
all: 'unset',
flex: '0 0 auto',
color: '$gray12',
padding: '0.6rem 0.8rem',
borderRadius: theme.borderRadius.form,
display: 'flex',
fontSize: '0.9rem',
lineHeight: 1,
alignItems: 'center',
justifyContent: 'center',
};
export const ToolbarButton = styled(ToolbarPrimitive.Button, {
...itemStyles,
cursor: 'pointer',
backgroundColor: '$gray4',
border: '1px solid $gray6',
});
export const ToolbarComboBox = styled('select', {
flex: '0 0 auto',
color: '$gray12',
display: 'inline-flex',
lineHeight: 1,
fontSize: '0.9rem',
margin: 0,
fontWeight: '300',
border: '1px solid $gray6',
padding: '0.5rem 0.25rem',
borderRadius: theme.borderRadius.form,
backgroundColor: '$gray2',
'&:hover': {
borderColor: '$teal7',
},
'&:focus': {
borderColor: '$teal7',
backgroundColor: '$gray3',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
});