diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 611dab2..36f4dbf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -394,6 +394,11 @@ } } }, + "@fontsource/space-mono": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@fontsource/space-mono/-/space-mono-4.5.0.tgz", + "integrity": "sha512-1tzGlLH2N/k1sovGSA4s7MpIRMciKeJbXKfdrKc1H84lg1FPcmiYGYdWiQA5MoQyjF6Bqx+IWpyIcOktMKvViA==" + }, "@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", @@ -433,6 +438,31 @@ "@babel/runtime": "^7.13.10" } }, + "@radix-ui/react-alert-dialog": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-0.1.5.tgz", + "integrity": "sha512-Lq9h3GSvw752e7dFll3UWvm4uWiTlYAXLFX6wr/VQPRoa7XaQO8/1NBu4ikLHAecGEd/uDGZLY3aP7ovGPQYtg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "0.1.0", + "@radix-ui/react-compose-refs": "0.1.0", + "@radix-ui/react-context": "0.1.1", + "@radix-ui/react-dialog": "0.1.5", + "@radix-ui/react-primitive": "0.1.3", + "@radix-ui/react-slot": "0.1.2" + }, + "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-checkbox": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-0.1.4.tgz", @@ -521,6 +551,103 @@ "@babel/runtime": "^7.13.10" } }, + "@radix-ui/react-dialog": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-0.1.5.tgz", + "integrity": "sha512-WftvXcQSszUphCTLQkkpBIkrYYU0IYqgIvACLQady4BN4YHDgdNlrwdg2ti9QrXgq1PZ+0S/6BIaA1dmSuRQ2g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "0.1.0", + "@radix-ui/react-compose-refs": "0.1.0", + "@radix-ui/react-context": "0.1.1", + "@radix-ui/react-dismissable-layer": "0.1.3", + "@radix-ui/react-focus-guards": "0.1.0", + "@radix-ui/react-focus-scope": "0.1.3", + "@radix-ui/react-id": "0.1.4", + "@radix-ui/react-portal": "0.1.3", + "@radix-ui/react-presence": "0.1.1", + "@radix-ui/react-primitive": "0.1.3", + "@radix-ui/react-slot": "0.1.2", + "@radix-ui/react-use-controllable-state": "0.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "^2.4.0" + }, + "dependencies": { + "@radix-ui/react-id": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.4.tgz", + "integrity": "sha512-/hq5m/D0ZfJWOS7TLF+G0l08KDRs87LBE46JkAvgKkg1fW4jkucx9At9D9vauIPSbdNmww5kXEp566hMlA8eXA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "0.1.0" + } + }, + "@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-dismissable-layer": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-0.1.3.tgz", + "integrity": "sha512-3veE7M8K13Qb+6+tC3DHWmWV9VMuuRoZvRLdrvz7biSraK/qkGBN4LbKZDaTdw2D2HS7RNpSd/sF8pFd3TaAgA==", + "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-use-body-pointer-events": "0.1.0", + "@radix-ui/react-use-callback-ref": "0.1.0", + "@radix-ui/react-use-escape-keydown": "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-focus-guards": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-0.1.0.tgz", + "integrity": "sha512-kRx/swAjEfBpQ3ns7J3H4uxpXuWCqN7MpALiSDOXiyo2vkWv0L9sxvbpZeTulINuE3CGMzicVMuNc/VWXjFKOg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-focus-scope": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-0.1.3.tgz", + "integrity": "sha512-bKi+lw14SriQqYWMBe13b/wvxSqYMC+3FylMUEwOKA6JrBoldpkhX5XffGDdpDRTTpjbncdH3H7d1PL5Bs7Ikg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "0.1.0", + "@radix-ui/react-primitive": "0.1.3", + "@radix-ui/react-use-callback-ref": "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-icons": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.0.3.tgz", @@ -547,6 +674,27 @@ "@radix-ui/react-primitive": "0.1.2" } }, + "@radix-ui/react-portal": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-0.1.3.tgz", + "integrity": "sha512-DrV+sPYLs0HhmX5/b7yRT6nLM9Nl6FtQe2KUG+46kiCOKQ+0XzNMO5hmeQtyq0mRf/qlC02rFu6OMsWpIqVsJg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "0.1.3", + "@radix-ui/react-use-layout-effect": "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-presence": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-0.1.1.tgz", @@ -646,6 +794,15 @@ } } }, + "@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", + "integrity": "sha512-svPyoHCcwOq/vpWNEvdH/yD91vN9p8BtiozNQbjVmJRxQ/vS12zqk70AxTGWe+2ZKHq2sggpEQNTv1JHyVFlnQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "0.1.0" + } + }, "@radix-ui/react-use-callback-ref": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-0.1.0.tgz", @@ -663,6 +820,15 @@ "@radix-ui/react-use-callback-ref": "0.1.0" } }, + "@radix-ui/react-use-escape-keydown": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-0.1.0.tgz", + "integrity": "sha512-tDLZbTGFmvXaazUXXv8kYbiCcbAE8yKgng9s95d8fCO+Eundv0Jngbn/hKPhDDs4jj9ChwRX5cDDnlaN+ugYYQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "0.1.0" + } + }, "@radix-ui/react-use-layout-effect": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-0.1.0.tgz", @@ -952,6 +1118,14 @@ "sprintf-js": "~1.0.2" } }, + "aria-hidden": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.1.3.tgz", + "integrity": "sha512-RhVWFtKH5BiGMycI72q2RAFMLQi8JP9bLuQXgR5a8Znp7P5KOIADSJeyfI8PCVxLEp067B2HbP5JIiI/PXIZeA==", + "requires": { + "tslib": "^1.0.0" + } + }, "array-includes": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz", @@ -1206,6 +1380,11 @@ "object-keys": "^1.0.12" } }, + "detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1859,6 +2038,11 @@ "has-symbols": "^1.0.1" } }, + "get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" + }, "get-symbol-description": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", @@ -2059,6 +2243,14 @@ "side-channel": "^1.0.4" } }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -2712,6 +2904,27 @@ "react-is": "^16.13.1" } }, + "react-remove-scroll": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.4.3.tgz", + "integrity": "sha512-lGWYXfV6jykJwbFpsuPdexKKzp96f3RbvGapDSIdcyGvHb7/eqyn46C7/6h+rUzYar1j5mdU+XECITHXCKBk9Q==", + "requires": { + "react-remove-scroll-bar": "^2.1.0", + "react-style-singleton": "^2.1.0", + "tslib": "^1.0.0", + "use-callback-ref": "^1.2.3", + "use-sidecar": "^1.0.1" + } + }, + "react-remove-scroll-bar": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.2.0.tgz", + "integrity": "sha512-UU9ZBP1wdMR8qoUs7owiVcpaPwsQxUDC2lypP6mmixaGlARZa7ZIBx1jcuObLdhMOvCsnZcvetOho0wzPa9PYg==", + "requires": { + "react-style-singleton": "^2.1.0", + "tslib": "^1.0.0" + } + }, "react-router": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.1.1.tgz", @@ -2729,6 +2942,16 @@ "react-router": "6.1.1" } }, + "react-style-singleton": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.1.1.tgz", + "integrity": "sha512-jNRp07Jza6CBqdRKNgGhT3u9umWvils1xsuMOjZlghBDH2MU0PL2WZor4PGYjXpnRCa9DQSlHMs/xnABWOwYbA==", + "requires": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^1.0.0" + } + }, "react-toastify": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.1.0.tgz", @@ -3143,8 +3366,7 @@ "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "tsutils": { "version": "3.21.0", @@ -3196,6 +3418,20 @@ "punycode": "^2.1.0" } }, + "use-callback-ref": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.5.tgz", + "integrity": "sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg==" + }, + "use-sidecar": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.0.5.tgz", + "integrity": "sha512-k9jnrjYNwN6xYLj1iaGhonDghfvmeTmYjAiGvOr7clwKfPjMXJf4/HOr7oT5tJwYafgp2tG2l3eZEOfoELiMcA==", + "requires": { + "detect-node-es": "^1.1.0", + "tslib": "^1.9.3" + } + }, "v8-compile-cache": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index dcd538e..96c6cfa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,8 +3,11 @@ "version": "1.0.0", "dependencies": { "@billjs/event-emitter": "^1.0.3", + "@fontsource/space-mono": "^4.5.0", "@radix-ui/colors": "^0.1.8", + "@radix-ui/react-alert-dialog": "^0.1.5", "@radix-ui/react-checkbox": "^0.1.4", + "@radix-ui/react-dialog": "^0.1.5", "@radix-ui/react-icons": "^1.0.3", "@radix-ui/react-label": "^0.1.3", "@radix-ui/react-tabs": "^0.1.4", diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 6b421c1..a07bfb3 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -4,6 +4,7 @@ import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; import 'inter-ui/inter.css'; +import '@fontsource/space-mono/index.css'; import 'normalize.css/normalize.css'; import 'react-toastify/dist/ReactToastify.css'; import 'overlayscrollbars/css/OverlayScrollbars.css'; diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index f739ce2..0197d46 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -104,14 +104,40 @@ "title": "Bot commands", "desc": "Define custom chat commands to set up autoresponders, counters, etc.", "add-button": "New command", - "search-placeholder": "Search command by name" + "search-placeholder": "Search command by name", + "command-header-new": "New command", + "command-header-edit": "Edit command", + "command-name": "Command name", + "command-name-placeholder": "!command", + "command-desc": "Description (optional)", + "command-desc-placeholder": "This command does something", + "command-response": "Response", + "command-response-placeholder": "Hello {0}!", + "command-acl": "Access level", + "command-acl-help": "This specifies the minimum level, eg. if you choose VIPs, moderators and streamer can still use the command", + "command-action-new": "Create", + "command-action-edit": "Edit", + "acl": { + "everyone": "Everyone", + "subscribers": "Subscribers", + "vip": "VIPs", + "moderators": "Moderators", + "streamer": "Streamer only" + }, + "remove-command-title": "Remove command {{name}}?" } }, "form-actions": { "save": "Save", "saving": "Saving...", "saved": "Saved", - "error": "Error" + "error": "Error", + "edit": "Edit", + "enable": "Enable", + "disable": "Disable", + "delete": "Delete", + "cancel": "Cancel", + "ok": "OK" }, "debug": { "dev-build": "Development build" diff --git a/frontend/src/store/api/types.ts b/frontend/src/store/api/types.ts index 709814e..d852bdd 100644 --- a/frontend/src/store/api/types.ts +++ b/frontend/src/store/api/types.ts @@ -25,12 +25,15 @@ interface TwitchBotConfig { chat_history: number; } -export type AccessLevelType = - | 'everyone' - | 'subscribers' - | 'vip' - | 'moderators' - | 'streamer'; +export const accessLevels = [ + 'everyone', + 'subscribers', + 'vip', + 'moderators', + 'streamer', +] as const; + +export type AccessLevelType = typeof accessLevels[number]; export interface TwitchBotCustomCommand { description: string; diff --git a/frontend/src/ui/components/AlertContent.tsx b/frontend/src/ui/components/AlertContent.tsx new file mode 100644 index 0000000..d6dc235 --- /dev/null +++ b/frontend/src/ui/components/AlertContent.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { VariantProps } from '@stitches/react'; +import { useTranslation } from 'react-i18next'; +import { + AlertOverlay, + AlertContainer, + AlertTitle, + AlertDescription, + AlertActions, + AlertAction, + AlertCancel, +} from '../theme/alert'; +import { Button } from '../theme'; + +export interface DialogProps { + title?: string; + description?: string; + actionText?: string; + showCancel?: boolean; + cancelText?: string; + actionButtonProps?: VariantProps; + variation?: 'default' | 'danger'; + onAction?: () => void; +} + +function DialogContent({ + title, + description, + children, + actionText, + actionButtonProps, + showCancel, + cancelText, + variation, + onAction, +}: React.PropsWithChildren) { + const { t } = useTranslation(); + + return ( + + + + {title && ( + {title} + )} + {description && ( + + {description} + + )} + {children} + + + + + {showCancel && ( + + + + )} + + + + ); +} + +export default React.memo(DialogContent); diff --git a/frontend/src/ui/components/DialogContent.tsx b/frontend/src/ui/components/DialogContent.tsx new file mode 100644 index 0000000..716e259 --- /dev/null +++ b/frontend/src/ui/components/DialogContent.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { + DialogOverlay, + DialogContainer, + IconButton, + DialogTitle, + DialogDescription, +} from '../theme'; + +export interface DialogProps { + title?: string; + description?: string; + closeButton?: boolean; +} + +function DialogContent({ + title, + description, + children, +}: React.PropsWithChildren) { + return ( + + + + {title && {title}} + {description && {description}} + {children} + + + + + + + + + ); +} + +export default React.memo(DialogContent); diff --git a/frontend/src/ui/components/utils/SaveButton.tsx b/frontend/src/ui/components/utils/SaveButton.tsx index cb8fd1b..158dc70 100644 --- a/frontend/src/ui/components/utils/SaveButton.tsx +++ b/frontend/src/ui/components/utils/SaveButton.tsx @@ -27,7 +27,7 @@ function SaveButton( ); default: - return ; + return ; } } diff --git a/frontend/src/ui/pages/BackendIntegration.tsx b/frontend/src/ui/pages/BackendIntegration.tsx index 8d5929e..4a27712 100644 --- a/frontend/src/ui/pages/BackendIntegration.tsx +++ b/frontend/src/ui/pages/BackendIntegration.tsx @@ -183,7 +183,7 @@ function WebhookIntegration() { return ( <>

{t('pages.stulbe.auth-message')}

- {t('pages.stulbe.current-status')} @@ -191,7 +191,7 @@ function WebhookIntegration() { {t('pages.stulbe.sim-events')} {Object.keys(eventSubTestFn).map((ev: keyof typeof eventsubTests) => ( - ))} diff --git a/frontend/src/ui/pages/BotCommands.tsx b/frontend/src/ui/pages/BotCommands.tsx index b0655a0..f3aafc1 100644 --- a/frontend/src/ui/pages/BotCommands.tsx +++ b/frontend/src/ui/pages/BotCommands.tsx @@ -1,18 +1,330 @@ import { PlusIcon } from '@radix-ui/react-icons'; -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useModule } from '../../lib/react-utils'; +import { modules } from '../../store/api/reducer'; +import { + accessLevels, + AccessLevelType, + TwitchBotCustomCommand, +} from '../../store/api/types'; +import AlertContent from '../components/AlertContent'; +import DialogContent from '../components/DialogContent'; import { Button, + ComboBox, + Dialog, + DialogActions, + DialogClose, + Field, + FieldNote, FlexRow, InputBox, + Label, PageContainer, PageHeader, PageTitle, + styled, + Textarea, TextBlock, } from '../theme'; +import { Alert, AlertTrigger } from '../theme/alert'; + +const CommandList = styled('div', { marginTop: '1rem' }); +const CommandItemContainer = 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 CommandHeader = styled('header', { + display: 'flex', + gap: '0.5rem', + alignItems: 'center', + marginBottom: '0.4rem', +}); +const CommandName = styled('span', { + color: '$teal10', + fontWeight: 'bold', + variants: { + status: { + enabled: {}, + disabled: { + color: '$gray10', + }, + }, + }, +}); +const CommandDescription = styled('span', { + flex: 1, +}); +const CommandActions = styled('div', { + display: 'flex', + alignItems: 'center', + gap: '0.25rem', +}); +const CommandText = styled('div', { + fontFamily: 'Space Mono', + fontSize: '10pt', + margin: '-0.5rem', + marginTop: '0', + padding: '0.5rem', + backgroundColor: '$gray4', + lineHeight: '1.2rem', +}); +const ACLIndicator = styled('span', { + fontFamily: 'Space Mono', + fontSize: '10pt', + marginRight: '0.5rem', +}); + +interface CommandItemProps { + name: string; + item: TwitchBotCustomCommand; + onToggle?: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +function CommandItemEl({ + name, + item, + onToggle, + onEdit, + onDelete, +}: CommandItemProps): React.ReactElement { + const { t } = useTranslation(); + + return ( + + + + {name} + + {item.description} + + {item.access_level !== 'everyone' && ( + + {t(`pages.botcommands.acl.${item.access_level}`)} + {item.access_level !== 'streamer' && '+'} + + )} + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + {item.response} + + ); +} + +const CommandItem = React.memo(CommandItemEl); + +type DialogPrompt = + | { kind: 'new' } + | { kind: 'edit'; name: string; item: TwitchBotCustomCommand }; + +function CommandDialog({ + kind, + name, + item, + onSubmit, +}: { + kind: 'new' | 'edit'; + name?: string; + item?: TwitchBotCustomCommand; + onSubmit?: (name: string, item: TwitchBotCustomCommand) => void; +}) { + const [commandName, setCommandName] = useState(name ?? ''); + const [description, setDescription] = useState(item?.description ?? ''); + const [response, setResponse] = useState(item?.response ?? ''); + const [accessLevel, setAccessLevel] = useState( + item?.access_level ?? 'everyone', + ); + const { t } = useTranslation(); + + return ( + +
{ + e.preventDefault(); + if (onSubmit) { + onSubmit(commandName, { + ...item, + description, + response, + access_level: accessLevel as AccessLevelType, + }); + } + }} + > + + + setCommandName(e.target.value)} + placeholder={t('pages.botcommands.command-name-placeholder')} + /> + + + + setDescription(e.target.value)} + placeholder={t('pages.botcommands.command-desc-placeholder')} + /> + + + + + + + + setAccessLevel(e.target.value as AccessLevelType)} + > + {accessLevels.map((level) => ( + + ))} + + {t('pages.botcommands.command-acl-help')} + + + + + + + +
+
+ ); +} export default function TwitchBotCommandsPage(): React.ReactElement { + const [botCommands, setBotCommands] = useModule(modules.twitchBotCommands); + const [commandFilter, setCommandFilter] = useState(''); + const [activeDialog, setActiveDialog] = useState(null); const { t } = useTranslation(); + const dispatch = useDispatch(); + + const commandFilterLC = commandFilter.toLowerCase(); + + const setCommand = (newName: string, data: TwitchBotCustomCommand): void => { + switch (activeDialog.kind) { + case 'new': + dispatch( + setBotCommands({ + ...botCommands, + [newName]: { + ...data, + enabled: true, + }, + }), + ); + break; + case 'edit': { + const oldName = activeDialog.name; + dispatch( + setBotCommands({ + ...botCommands, + [oldName]: undefined, + [newName]: data, + }), + ); + break; + } + } + setActiveDialog(null); + }; + + const deleteCommand = (cmd: string): void => { + dispatch( + setBotCommands({ + ...botCommands, + [cmd]: undefined, + }), + ); + }; + + const toggleCommand = (cmd: string): void => { + dispatch( + setBotCommands({ + ...botCommands, + [cmd]: { + ...botCommands[cmd], + enabled: !botCommands[cmd].enabled, + }, + }), + ); + }; return ( @@ -22,14 +334,58 @@ export default function TwitchBotCommandsPage(): React.ReactElement { - + setCommandFilter(e.target.value)} /> + + {Object.keys(botCommands ?? {}) + ?.filter((cmd) => cmd.toLowerCase().includes(commandFilterLC)) + .sort() + .map((cmd) => ( + toggleCommand(cmd)} + onEdit={() => + setActiveDialog({ + kind: 'edit', + name: cmd, + item: botCommands[cmd], + }) + } + onDelete={() => deleteCommand(cmd)} + /> + ))} + + + { + if (!open) { + // Reset dialog status on dialog close + setActiveDialog(null); + } + }} + > + {activeDialog && ( + setCommand(name, data)} + /> + )} + ); } diff --git a/frontend/src/ui/theme/alert.ts b/frontend/src/ui/theme/alert.ts new file mode 100644 index 0000000..ce89792 --- /dev/null +++ b/frontend/src/ui/theme/alert.ts @@ -0,0 +1,118 @@ +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { keyframes } from '@stitches/react'; +import { styled } from './theme'; + +export const Alert = AlertDialogPrimitive.Root; +export const AlertTrigger = AlertDialogPrimitive.Trigger; +export const AlertAction = AlertDialogPrimitive.AlertDialogAction; +export const AlertCancel = AlertDialogPrimitive.AlertDialogCancel; + +const overlayShow = keyframes({ + '0%': { opacity: 0 }, + '100%': { opacity: 1 }, +}); + +const contentShow = keyframes({ + '0%': { opacity: 0, transform: 'translate(-50%, -48%) scale(.96)' }, + '100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, +}); + +export const AlertOverlay = styled(AlertDialogPrimitive.Overlay, { + backgroundColor: '$blackA10', + position: 'fixed', + inset: 0, + '@media (prefers-reduced-motion: no-preference)': { + animation: `${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`, + }, +}); + +export const AlertContainer = styled(AlertDialogPrimitive.Content, { + backgroundColor: '$gray2', + borderRadius: '0.25rem', + boxShadow: + 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px', + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '90vw', + maxWidth: '600px', + maxHeight: '85vh', + padding: '1rem', + '@media (prefers-reduced-motion: no-preference)': { + animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`, + }, + border: '2px solid $teal8', + '&:focus': { outline: 'none' }, + variants: { + variation: { + default: {}, + danger: { + borderColor: '$red8', + }, + }, + }, +}); + +export const AlertTitle = styled(AlertDialogPrimitive.Title, { + fontWeight: 'bold', + color: '$teal12', + fontSize: '15pt', + borderBottom: '1px solid $teal6', + margin: '-1rem', + marginBottom: '1.5rem', + padding: '1rem', + lineHeight: '1.25', + variants: { + variation: { + default: {}, + danger: { + borderBottomColor: '$red6', + }, + }, + }, +}); + +export const AlertDescription = styled(AlertDialogPrimitive.Description, { + margin: '10px 0 20px', + color: '$teal12', + fontSize: 15, + lineHeight: 1.5, + variants: { + variation: { + default: {}, + danger: { + borderBottomColor: '$red12', + }, + }, + }, +}); + +export const AlertActions = styled('div', { + display: 'flex', + gap: '0.5rem', + justifyContent: 'flex-end', + borderTop: '1px solid $gray6', + margin: '-1rem', + marginTop: '1.5rem', + padding: '1rem 1.5rem', +}); + +export const IconButton = styled('button', { + all: 'unset', + fontFamily: 'inherit', + borderRadius: '100%', + height: 25, + width: 25, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: '$teal11', + position: 'absolute', + cursor: 'pointer', + top: 15, + right: 15, + + '&:hover': { backgroundColor: '$teal4' }, + '&:focus': { boxShadow: `0 0 0 2px $teal7` }, +}); diff --git a/frontend/src/ui/theme/dialog.ts b/frontend/src/ui/theme/dialog.ts new file mode 100644 index 0000000..f7281f1 --- /dev/null +++ b/frontend/src/ui/theme/dialog.ts @@ -0,0 +1,92 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { keyframes } from '@stitches/react'; +import { styled } from './theme'; + +export const Dialog = DialogPrimitive.Root; +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogClose = DialogPrimitive.Close; + +const overlayShow = keyframes({ + '0%': { opacity: 0 }, + '100%': { opacity: 1 }, +}); + +const contentShow = keyframes({ + '0%': { opacity: 0, transform: 'translate(-50%, -48%) scale(.96)' }, + '100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' }, +}); + +export const DialogOverlay = styled(DialogPrimitive.Overlay, { + backgroundColor: '$blackA10', + position: 'fixed', + inset: 0, + '@media (prefers-reduced-motion: no-preference)': { + animation: `${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`, + }, +}); + +export const DialogContainer = styled(DialogPrimitive.Content, { + backgroundColor: '$gray2', + borderRadius: '0.25rem', + boxShadow: + 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px', + position: 'fixed', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '90vw', + maxWidth: '600px', + maxHeight: '85vh', + padding: '1rem', + '@media (prefers-reduced-motion: no-preference)': { + animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`, + }, + '&:focus': { outline: 'none' }, +}); + +export const DialogTitle = styled(DialogPrimitive.Title, { + fontWeight: 'bold', + color: '$teal12', + fontSize: '15pt', + borderBottom: '1px solid $teal6', + margin: '-1rem', + marginBottom: '1.5rem', + padding: '1rem', + lineHeight: '1.25', +}); + +export const DialogDescription = styled(DialogPrimitive.Description, { + margin: '10px 0 20px', + color: '$teal11', + fontSize: 15, + lineHeight: 1.5, +}); + +export const DialogActions = styled('div', { + display: 'flex', + gap: '0.5rem', + justifyContent: 'flex-end', + borderTop: '1px solid $gray6', + margin: '-1rem', + marginTop: '1.5rem', + padding: '1rem 1.5rem', +}); + +export const IconButton = styled('button', { + all: 'unset', + fontFamily: 'inherit', + borderRadius: '100%', + height: 25, + width: 25, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: '$teal11', + position: 'absolute', + cursor: 'pointer', + top: 15, + right: 15, + + '&:hover': { backgroundColor: '$teal4' }, + '&:focus': { boxShadow: `0 0 0 2px $teal7` }, +}); diff --git a/frontend/src/ui/theme/forms.ts b/frontend/src/ui/theme/forms.ts index e3f47d3..1070e71 100644 --- a/frontend/src/ui/theme/forms.ts +++ b/frontend/src/ui/theme/forms.ts @@ -10,6 +10,11 @@ export const Field = styled('fieldset', { alignItems: 'center', gap: '0.5rem', variants: { + spacing: { + narrow: { + marginBottom: '1rem', + }, + }, size: { fullWidth: { flexDirection: 'column', @@ -56,6 +61,27 @@ export const InputBox = styled('input', { }, }); +export const Textarea = styled('textarea', { + all: 'unset', + fontWeight: '300', + border: '1px solid $gray6', + padding: '0.5rem', + borderRadius: '0.3rem', + backgroundColor: '$gray2', + '&:hover': { + borderColor: '$teal7', + }, + '&:focus': { + borderColor: '$teal7', + backgroundColor: '$gray3', + }, + '&:disabled': { + backgroundColor: '$gray4', + borderColor: '$gray5', + color: '$gray8', + }, +}); + export const ButtonGroup = styled('div', { display: 'flex', gap: '0.5rem', @@ -68,25 +94,52 @@ export const Button = styled('button', { padding: '0.5rem 1rem', borderRadius: '0.3rem', fontSize: '1.1rem', - border: '1px solid $teal6', - backgroundColor: '$teal4', + border: '1px solid $gray6', + backgroundColor: '$gray4', display: 'flex', alignItems: 'center', gap: '0.5rem', '&:hover': { - backgroundColor: '$teal5', + backgroundColor: '$gray5', + borderColor: '$gray8', }, '&:active': { - background: '$teal6', + background: '$gray6', }, transition: 'all 0.2s', variants: { + styling: { + link: { + backgroundColor: 'transparent', + border: 'none', + color: '$teal11', + textDecoration: 'underline', + }, + }, + size: { + small: { + padding: '0.3rem 0.5rem', + fontSize: '0.9rem', + }, + }, variation: { + primary: { + border: '1px solid $teal6', + backgroundColor: '$teal4', + '&:hover': { + backgroundColor: '$teal5', + borderColor: '$teal8', + }, + '&:active': { + background: '$teal6', + }, + }, success: { border: '1px solid $grass6', backgroundColor: '$grass4', '&:hover': { backgroundColor: '$grass5', + borderColor: '$grass8', }, '&:active': { background: '$grass6', @@ -97,6 +150,18 @@ export const Button = styled('button', { backgroundColor: '$red4', '&:hover': { backgroundColor: '$red5', + borderColor: '$red8', + }, + '&:active': { + background: '$red6', + }, + }, + danger: { + border: '1px solid $red6', + backgroundColor: '$red4', + '&:hover': { + backgroundColor: '$red5', + borderColor: '$red8', }, '&:active': { background: '$red6', @@ -106,6 +171,28 @@ export const Button = styled('button', { }, }); +export const ComboBox = styled('select', { + margin: 0, + color: '$teal13', + fontWeight: '300', + border: '1px solid $gray6', + padding: '0.5rem', + borderRadius: '0.3rem', + backgroundColor: '$gray2', + '&:hover': { + borderColor: '$teal7', + }, + '&:focus': { + borderColor: '$teal7', + backgroundColor: '$gray3', + }, + '&:disabled': { + backgroundColor: '$gray4', + borderColor: '$gray5', + color: '$gray8', + }, +}); + export const Checkbox = styled(CheckboxPrimitive.Root, { all: 'unset', width: 25, diff --git a/frontend/src/ui/theme/index.ts b/frontend/src/ui/theme/index.ts index e5411fb..2dcc540 100644 --- a/frontend/src/ui/theme/index.ts +++ b/frontend/src/ui/theme/index.ts @@ -1,6 +1,7 @@ -export * from './theme'; export * from './brand'; +export * from './dialog'; export * from './forms'; export * from './pages'; export * from './tabs'; +export * from './theme'; export * from './utils'; diff --git a/frontend/src/ui/theme/theme.ts b/frontend/src/ui/theme/theme.ts index f828caf..e7e88df 100644 --- a/frontend/src/ui/theme/theme.ts +++ b/frontend/src/ui/theme/theme.ts @@ -4,6 +4,7 @@ import { yellowDark, grassDark, redDark, + blackA, } from '@radix-ui/colors'; import { globalCss, createStitches } from '@stitches/react'; @@ -34,6 +35,7 @@ export const { styled, theme } = createStitches({ ...yellowDark, ...grassDark, ...redDark, + ...blackA, }, }, });