From dd15e6525148733739d57faea10bf10e8cb61491 Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Wed, 12 Jan 2022 13:20:30 +0100 Subject: [PATCH] Re-add pagelist and WIP LoyaltyQueue --- frontend/package-lock.json | 92 +++++++++++ frontend/package.json | 1 + frontend/src/locale/en/translation.json | 16 ++ frontend/src/ui/App.tsx | 2 + frontend/src/ui/components/Interval.tsx | 1 + frontend/src/ui/components/PageList.tsx | 167 ++++++++++++++++++++ frontend/src/ui/pages/BotCommands.tsx | 4 +- frontend/src/ui/pages/LoyaltyConfig.tsx | 202 ++++++++++++------------ frontend/src/ui/pages/LoyaltyQueue.tsx | 145 +++++++++++++++++ frontend/src/ui/theme/index.ts | 1 + frontend/src/ui/theme/theme.ts | 6 + frontend/src/ui/theme/toolbar.ts | 59 +++++++ 12 files changed, 591 insertions(+), 105 deletions(-) create mode 100644 frontend/src/ui/components/PageList.tsx create mode 100644 frontend/src/ui/pages/LoyaltyQueue.tsx create mode 100644 frontend/src/ui/theme/toolbar.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02910c1..ebbf0b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 57969bb..37e6e9b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 60b350d..bb809de 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -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" } } diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index a034cb4..b69a747 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -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 { /> } /> } /> + } /> diff --git a/frontend/src/ui/components/Interval.tsx b/frontend/src/ui/components/Interval.tsx index 8a890cb..57451e8 100644 --- a/frontend/src/ui/components/Interval.tsx +++ b/frontend/src/ui/components/Interval.tsx @@ -57,6 +57,7 @@ function Interval({ type="number" border="none" required={required} + disabled={!active} css={{ maxWidth: '5rem', borderRightWidth: '1px', diff --git a/frontend/src/ui/components/PageList.tsx b/frontend/src/ui/components/PageList.tsx new file mode 100644 index 0000000..0118456 --- /dev/null +++ b/frontend/src/ui/components/PageList.tsx @@ -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 ( + + + onPageChange(current - 1)} + css={{ + '@mobile': { flex: 1 }, + '@medium': { flex: 0 }, + }} + > + ‹ + + = max} + onClick={() => onPageChange(current + 1)} + css={{ + '@mobile': { flex: 1 }, + '@medium': { flex: 0 }, + }} + > + › + + onSelectChange(Number(ev.target.value))} + css={{ + textAlign: 'center', + '@mobile': { flex: 1 }, + '@medium': { flex: 0 }, + }} + > + + + + + + + + {current > min ? ( + onPageChange(min)} + > + {min} + + ) : null} + {current > min + 2 ? ( + + ) : null} + + {current > min + 1 ? ( + onPageChange(current - 1)} + > + {current - 1} + + ) : null} + + {current} + + {current < max ? ( + onPageChange(current + 1)} + > + {current + 1} + + ) : null} + {current < max - 2 ? ( + + ) : null} + {current < max - 1 ? ( + onPageChange(max)} + > + {max} + + ) : null} + + + ); +} + +export default React.memo(PageList); diff --git a/frontend/src/ui/pages/BotCommands.tsx b/frontend/src/ui/pages/BotCommands.tsx index 7be23c1..0b90cde 100644 --- a/frontend/src/ui/pages/BotCommands.tsx +++ b/frontend/src/ui/pages/BotCommands.tsx @@ -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', }, }, }, diff --git a/frontend/src/ui/pages/LoyaltyConfig.tsx b/frontend/src/ui/pages/LoyaltyConfig.tsx index 38ae791..8de5e24 100644 --- a/frontend/src/ui/pages/LoyaltyConfig.tsx +++ b/frontend/src/ui/pages/LoyaltyConfig.tsx @@ -56,106 +56,56 @@ export default function LoyaltySettingsPage(): React.ReactElement { - {active && ( -
{ - e.preventDefault(); - if (!(e.target as HTMLFormElement).checkValidity()) { - return; + { + e.preventDefault(); + if (!(e.target as HTMLFormElement).checkValidity()) { + return; + } + dispatch(setConfig(config)); + }} + > + + + + dispatch( + apiReducer.actions.loyaltyConfigChanged({ + ...config, + currency: e.target.value, + }), + ) } - dispatch(setConfig(config)); - }} - > - - - - dispatch( - apiReducer.actions.loyaltyConfigChanged({ - ...config, - currency: e.target.value, - }), - ) - } - /> - - {t('pages.loyalty-settings.currency-name-hint')} - - + /> + + {t('pages.loyalty-settings.currency-name-hint')} + + - - - - { - const intNum = parseInt(e.target.value, 10); - if (Number.isNaN(intNum)) { - return; - } - dispatch( - apiReducer.actions.loyaltyConfigChanged({ - ...config, - points: { - ...config.points, - amount: intNum, - }, - }), - ); - }} - /> -
{t('pages.loyalty-settings.every')}
- { - dispatch( - apiReducer.actions.loyaltyConfigChanged({ - ...(config ?? {}), - points: { - ...config?.points, - interval, - }, - }), - ); - }} - active={!busy} - min={5} - required={true} - /> -
-
- - - + + + { 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, }, }), ); }} /> - - {t('pages.loyalty-settings.bonus-points-hint')} - - +
{t('pages.loyalty-settings.every')}
+ { + dispatch( + apiReducer.actions.loyaltyConfigChanged({ + ...(config ?? {}), + points: { + ...config?.points, + interval, + }, + }), + ); + }} + active={active && !busy} + min={5} + required={true} + /> + +
- - - )} + + + { + const intNum = parseInt(e.target.value, 10); + if (Number.isNaN(intNum)) { + return; + } + dispatch( + apiReducer.actions.loyaltyConfigChanged({ + ...config, + points: { + ...config.points, + activity_bonus: intNum, + }, + }), + ); + }} + /> + {t('pages.loyalty-settings.bonus-points-hint')} + + + + ); } diff --git a/frontend/src/ui/pages/LoyaltyQueue.tsx b/frontend/src/ui/pages/LoyaltyQueue.tsx new file mode 100644 index 0000000..f83ad7e --- /dev/null +++ b/frontend/src/ui/pages/LoyaltyQueue.tsx @@ -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({ + 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 ( + <> + setEntriesPerPage(em)} + onPageChange={(p) => setPage(p - 1)} + /> + + {paged.map(([user, { points }]) => ( +
+ {user} - {points} +
+ ))} + + setEntriesPerPage(em)} + onPageChange={(p) => setPage(p - 1)} + /> + + ); +} + +export default function LoyaltyQueuePage(): React.ReactElement { + const { t } = useTranslation(); + + return ( + + + {t('pages.loyalty-queue.title')} + {t('pages.loyalty-queue.subtitle')} + + + + + {t('pages.loyalty-queue.queue-tab')} + + + {t('pages.loyalty-queue.users-tab')} + + + + + + + + + + + ); +} diff --git a/frontend/src/ui/theme/index.ts b/frontend/src/ui/theme/index.ts index 2dcc540..3b55fa8 100644 --- a/frontend/src/ui/theme/index.ts +++ b/frontend/src/ui/theme/index.ts @@ -4,4 +4,5 @@ export * from './forms'; export * from './pages'; export * from './tabs'; export * from './theme'; +export * from './toolbar'; export * from './utils'; diff --git a/frontend/src/ui/theme/theme.ts b/frontend/src/ui/theme/theme.ts index 8b642c4..efee80d 100644 --- a/frontend/src/ui/theme/theme.ts +++ b/frontend/src/ui/theme/theme.ts @@ -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)', + }, }); diff --git a/frontend/src/ui/theme/toolbar.ts b/frontend/src/ui/theme/toolbar.ts new file mode 100644 index 0000000..77d7569 --- /dev/null +++ b/frontend/src/ui/theme/toolbar.ts @@ -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', + }, +});