├── server
├── db
│ └── .gitkeep
├── data
│ └── sounds
│ │ └── .gitkeep
├── .env.example
├── .eslintrc
├── src
│ ├── enum.ts
│ ├── socket.ts
│ ├── config.ts
│ ├── utils.ts
│ ├── types.ts
│ ├── index.ts
│ ├── soundupload.ts
│ ├── users.ts
│ ├── datastore.ts
│ ├── endpoints.ts
│ └── twitch.ts
├── tsconfig.json
└── package.json
├── client
├── public
│ ├── .gitkeep
│ └── index.html
├── src
│ ├── declarations.d.ts
│ ├── theme
│ │ ├── overrides
│ │ │ └── index.ts
│ │ ├── index.ts
│ │ ├── palette.ts
│ │ └── typography.ts
│ ├── components
│ │ ├── player
│ │ │ ├── index.ts
│ │ │ └── PlayerMain.tsx
│ │ ├── dashboard
│ │ │ ├── DashboardMain.tsx
│ │ │ ├── Layout.tsx
│ │ │ ├── ConnectedCard.tsx
│ │ │ ├── index.ts
│ │ │ ├── IconHelper.tsx
│ │ │ ├── ConnectionPage.tsx
│ │ │ ├── UserPage.tsx
│ │ │ ├── SoundPage.tsx
│ │ │ ├── HomePage.tsx
│ │ │ ├── UserTable.tsx
│ │ │ ├── ConnectionForm.tsx
│ │ │ ├── SoundTable.tsx
│ │ │ ├── dialogs
│ │ │ │ ├── EditUserDialog.tsx
│ │ │ │ ├── NewUserDialog.tsx
│ │ │ │ ├── EditSoundDialog.tsx
│ │ │ │ └── NewSoundDialog.tsx
│ │ │ └── Navigation.tsx
│ │ └── App.tsx
│ ├── enums
│ │ ├── userFlags.ts
│ │ └── accessLevels.ts
│ ├── config.ts
│ ├── hooks
│ │ ├── index.ts
│ │ ├── useRouter.ts
│ │ ├── useUsers.tsx
│ │ ├── useAppContext.tsx
│ │ └── useSounds.tsx
│ ├── index.tsx
│ ├── index.less
│ ├── types
│ │ └── index.ts
│ └── client.ts
├── .eslintrc
├── .babelrc
├── tsconfig.json
├── webpack.config.js
├── webpack.config.dev.js
└── package.json
├── script
├── run.bat
└── setup.bat
├── .gitignore
└── README.md
/server/db/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/declarations.d.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/server/data/sounds/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/theme/overrides/index.ts:
--------------------------------------------------------------------------------
1 | export default { }
2 |
--------------------------------------------------------------------------------
/script/run.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | color 5
3 | cd ../server
4 | @echo on
5 | npm run server
6 | @pause
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *node_modules
2 | *build
3 | *dist
4 | *.env
5 | *.vscode
6 |
7 | *db-*.json
8 |
9 | *.mp3
--------------------------------------------------------------------------------
/client/src/components/player/index.ts:
--------------------------------------------------------------------------------
1 | import PlayerMain from './PlayerMain'
2 |
3 | export {
4 | PlayerMain
5 | }
6 |
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | SERVER_PORT="8080"
2 | UUID_NAMESPACE="00 02 32 ef 70 30 a8 ea e4 c5 21 69 95 da 8a c5"
3 | ENVIRONMENT="PRODUCTION"
4 | SOCKET_PORT="8081"
--------------------------------------------------------------------------------
/client/src/enums/userFlags.ts:
--------------------------------------------------------------------------------
1 | import { UserFlags } from '../types'
2 |
3 | const userFlags: UserFlags[] = [
4 | 'ban',
5 | 'all-access'
6 | ]
7 |
8 | export default userFlags
9 |
--------------------------------------------------------------------------------
/server/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "jubic-typescript",
3 | "parserOptions": {
4 | "project": "./tsconfig.json"
5 | },
6 | "rules": {
7 | "import/no-duplicates": 0,
8 | }
9 | }
--------------------------------------------------------------------------------
/server/src/enum.ts:
--------------------------------------------------------------------------------
1 | export enum SOCKET {
2 | TWITCH = 'UPDATE_TWITCH',
3 | PLAYER = 'EVENT_PLAYER'
4 | }
5 |
6 | export enum PERMISSIONS {
7 | BANNED = 'ban',
8 | ALL_ACCESS = 'all-access'
9 | }
10 |
--------------------------------------------------------------------------------
/client/src/enums/accessLevels.ts:
--------------------------------------------------------------------------------
1 | import { AccessLevel } from '../types'
2 |
3 | const accessLevel: AccessLevel[] = [
4 | 'ALL',
5 | 'SUB',
6 | 'VIP',
7 | 'MOD'
8 | ]
9 |
10 | export default accessLevel
11 |
--------------------------------------------------------------------------------
/client/src/config.ts:
--------------------------------------------------------------------------------
1 | import { Config } from './types'
2 |
3 | const { hostname, protocol } = window.location
4 | const socketPort = 8081
5 |
6 | const config: Config = {
7 | socketUrl: `${protocol}//${hostname}:${socketPort}/socket`
8 | }
9 |
10 | export default config
11 |
--------------------------------------------------------------------------------
/script/setup.bat:
--------------------------------------------------------------------------------
1 | cd ../client
2 | call npm install
3 | cd ../server
4 | call npm install
5 | IF NOT EXIST ".env" (
6 | echo F | xcopy .env.example .env
7 | echo ======================
8 | echo .env file was created.
9 | echo ======================
10 | )
11 | npm run production
12 | @pause
--------------------------------------------------------------------------------
/client/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import useRouter from './useRouter'
2 | import { useSounds } from './useSounds'
3 | import { useAppContext } from './useAppContext'
4 | import { useUsers } from './useUsers'
5 |
6 | export {
7 | useRouter,
8 | useSounds,
9 | useAppContext,
10 | useUsers
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/DashboardMain.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BrowserRouter } from 'react-router-dom'
3 | import { Navigation } from '.'
4 |
5 | export default () => {
6 | return (
7 |
8 |
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Twitch-Play-Sounds
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/client/src/hooks/useRouter.ts:
--------------------------------------------------------------------------------
1 | import { useContext } from 'react'
2 | import { __RouterContext, RouteComponentProps } from 'react-router'
3 |
4 | export default (): RouteComponentProps => (
5 | useContext(__RouterContext) as RouteComponentProps
6 | )
7 |
--------------------------------------------------------------------------------
/client/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "jubic-typescript",
3 | "parserOptions": {
4 | "project": "./tsconfig.json"
5 | },
6 | "rules": {
7 | "camelcase": [0, {"properties": "never"}],
8 | },
9 | "globals": {
10 | "fetch": false,
11 | "FormData": false,
12 | "Audio": false
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/client/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react",
5 | "@babel/preset-typescript"
6 | ],
7 | "plugins": [
8 | "@babel/plugin-proposal-class-properties",
9 | "@babel/plugin-proposal-object-rest-spread",
10 | "@babel/plugin-syntax-dynamic-import"
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "module": "commonjs",
5 | "lib": [ "esnext", "dom", "dom.iterable" ],
6 | "jsx": "preserve",
7 | "noEmit": true,
8 | "downlevelIteration": true,
9 | "strict": true,
10 | "allowSyntheticDefaultImports": true,
11 | "esModuleInterop": true,
12 | },
13 | "include": [
14 | "./src/**/*.ts",
15 | "./src/**/*.tsx"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/socket.ts:
--------------------------------------------------------------------------------
1 | import * as SocketIO from 'socket.io'
2 |
3 | import { SOCKET } from './enum'
4 |
5 | import config from './config'
6 |
7 | const io = SocketIO().listen(config.socketIoPort)
8 |
9 | export const socketPath = '/socket'
10 |
11 | io.on('connection', () => {
12 | io.of(socketPath).emit('connected')
13 | })
14 |
15 | export const dispatchSocket = (
16 | method: SOCKET,
17 | data: any
18 | ) => {
19 | io.of(socketPath).emit(method, data)
20 | }
21 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": { "*": ["types/*"] },
4 | "target": "es2015",
5 | "module": "commonJS",
6 | "noImplicitAny": false,
7 | "sourceMap": true,
8 | "strict": true,
9 | "allowJs": true,
10 | "lib": [
11 | "es2016",
12 | "es2015"
13 | ],
14 | "baseUrl": "./src/",
15 | "outDir": "./dist/"
16 | },
17 | "include": [
18 | "src/**/**/*.ts"
19 | ],
20 | "exclude": [
21 | "node_modules"
22 | ]
23 | }
--------------------------------------------------------------------------------
/client/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { createMuiTheme } from '@material-ui/core/styles'
2 |
3 | import palette, { customColors } from './palette'
4 | import typography from './typography'
5 | import overrides from './overrides'
6 |
7 | const theme = createMuiTheme({
8 | palette,
9 | typography,
10 | overrides,
11 | zIndex: {
12 | appBar: 1200,
13 | drawer: 1100,
14 | modal: 1300,
15 | snackbar: 1250
16 | }
17 | })
18 |
19 | export default theme
20 |
21 | export {
22 | customColors
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import { AppContainer } from 'react-hot-loader'
4 |
5 | import App from './components/App'
6 |
7 | const mount = document.getElementById('mount')
8 | const render = () => {
9 | if (!mount) {
10 | console.error('No mountpoint found!')
11 | return
12 | }
13 |
14 | ReactDOM.render(, mount)
15 | }
16 |
17 | render()
18 |
19 | if (module.hot) {
20 | module.hot.accept('./components/App', render)
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/config.ts:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv'
2 |
3 | dotenv.config()
4 |
5 | interface ConfigType {
6 | port: number,
7 | uuidNameSpace: string,
8 | environment: string,
9 | socketIoPort: number
10 | }
11 |
12 | const config: ConfigType = {
13 | port: Number(process.env.SERVER_PORT) || 8080,
14 | socketIoPort: Number(process.env.SOCKET_PORT) || 8081,
15 | uuidNameSpace: process.env.UUID_NAMESPACE || '00 02 32 ef 70 30 a8 ea e4 c5 21 69 95 da 8a c5',
16 | environment: process.env.ENVIRONMENT || 'PRODUCTION'
17 | }
18 |
19 | export default config
20 |
--------------------------------------------------------------------------------
/client/src/index.less:
--------------------------------------------------------------------------------
1 | .contains-inv-actions{
2 | .inv-actions{
3 | transition: opacity 75ms;
4 | opacity: 0;
5 | display: flex;
6 | }
7 |
8 | &:hover .inv-actions{
9 | opacity: 1;
10 | }
11 | }
12 |
13 |
14 | .icon-wrapper{
15 | color: white;
16 | padding: 3px;
17 | border-radius: 6px;
18 | margin-top: 4px;
19 | &.ALL {
20 | background-color: #b1b1b1
21 | }
22 |
23 | &.MOD {
24 | background-color: #3cbd00
25 | }
26 |
27 | &.SUB {
28 | background-color: #0e00d4
29 | }
30 |
31 | &.VIP {
32 | background-color: #b60070
33 | }
34 | }
--------------------------------------------------------------------------------
/server/src/utils.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs'
2 |
3 | export const getFileExtension = (filename: string) => {
4 | return filename.split('.').pop()
5 | }
6 |
7 | export const deleteFile = (
8 | path: string
9 | ): Promise => {
10 | return new Promise((resolve, reject) => {
11 | try {
12 | resolve(fs.unlinkSync(path))
13 | }
14 | catch (err) {
15 | console.log(err)
16 | reject(Error)
17 | }
18 | })
19 | }
20 |
21 | export const renameFile = (
22 | oldPath: string,
23 | newPath: string
24 | ): Promise => {
25 | return new Promise((resolve, reject) => {
26 | fs.rename(`${oldPath}`, `${newPath}`, function (err) {
27 | if (err) reject(err)
28 | resolve()
29 | })
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/server/src/types.ts:
--------------------------------------------------------------------------------
1 | export type AccessLevel = 'ALL' | 'VIP' | 'MOD' | 'SUB'
2 | export type UserFlags = 'ban' | 'all-access'
3 |
4 | export interface Sound {
5 | id: string,
6 | access: AccessLevel,
7 | command: string,
8 | path: string,
9 | level: number
10 | }
11 |
12 | export interface SoundRequest {
13 | access: AccessLevel,
14 | command: string,
15 | path: string,
16 | level: number
17 | }
18 |
19 | export interface TwitchConfig {
20 | username: string | null,
21 | oauth: string | null,
22 | channels: string[] | null
23 | }
24 |
25 | export interface User {
26 | id: string,
27 | username: string,
28 | flags: UserFlags[]
29 | }
30 |
31 | export interface UserRequest {
32 | username: string,
33 | flags: UserFlags[]
34 | }
35 |
--------------------------------------------------------------------------------
/client/src/components/player/PlayerMain.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import openSocket from 'socket.io-client'
3 | import config from '../../config'
4 | import { Sound } from '../../types'
5 |
6 | export default () => {
7 | const _playSound = (sound: Sound) => {
8 | const fixed = `/${sound.path}`
9 | const audio = new Audio(fixed)
10 | const volumeLevel = (sound.level / 100)
11 | audio.volume = 0.75 * volumeLevel
12 | audio.play()
13 | }
14 |
15 | const subscribeToSocket = () => {
16 | const socket = openSocket(config.socketUrl)
17 |
18 | socket.on('EVENT_PLAYER', (sound: Sound) => _playSound(sound))
19 | }
20 |
21 | useEffect(() => {
22 | subscribeToSocket()
23 | }, [])
24 |
25 | return (
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import {
4 | Typography as T,
5 | Paper,
6 | makeStyles,
7 | createStyles,
8 | Theme
9 | } from '@material-ui/core'
10 |
11 | const useStyles = makeStyles((theme: Theme) =>
12 | createStyles({
13 | sectionHeader: {
14 | margin: theme.spacing(1, 0, 0.5)
15 | },
16 | paper: {
17 | padding: theme.spacing(2),
18 | marginBottom: theme.spacing(2)
19 | }
20 | })
21 | )
22 |
23 | interface Props {
24 | title: string,
25 | children: JSX.Element | JSX.Element[]
26 | }
27 |
28 | export default ({ title, children }: Props) => {
29 | const classes = useStyles()
30 |
31 | return (
32 |
33 | {title}
34 | {children}
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/ConnectedCard.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { TwitchUser } from '../../types'
3 | import {
4 | Typography as T,
5 | makeStyles,
6 | createStyles,
7 | Theme,
8 | Button
9 | } from '@material-ui/core'
10 |
11 | const useStyles = makeStyles((theme: Theme) =>
12 | createStyles({
13 | text: {
14 | marginTop: theme.spacing(2)
15 | }
16 | })
17 | )
18 |
19 | interface Props {
20 | user: TwitchUser,
21 | onLogOut: () => void
22 | }
23 |
24 | export default ({
25 | user,
26 | onLogOut
27 | }: Props) => {
28 | const classes = useStyles()
29 |
30 | return (
31 | <>
32 | {user.username}
33 |
41 | >
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'
3 | import { hot } from 'react-hot-loader'
4 | import { CssBaseline, MuiThemeProvider } from '@material-ui/core'
5 | import { AppProvider } from '../hooks/useAppContext'
6 |
7 | import theme from '../theme'
8 | import '../index.less'
9 |
10 | import { DashboardMain } from './dashboard'
11 | import { PlayerMain } from './player'
12 |
13 | const App = () => (
14 |
15 |
16 |
17 |
18 |
19 |
20 | } />
21 | } />
22 |
23 |
24 |
25 |
26 | )
27 |
28 | export default hot(module)(App)
29 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express'
2 | import * as path from 'path'
3 |
4 | import {
5 | Request,
6 | Response,
7 | Application
8 | } from 'express'
9 |
10 | import EndPoints from './endpoints'
11 | import config from './config'
12 | import { migrateSoundDatabase } from './datastore'
13 | import { migrateUserDatabase } from './users'
14 | import twitchConnection from './twitch'
15 |
16 | const app: Application = express()
17 |
18 | app.use(express.json())
19 | app.use(express.static('../client/build'))
20 |
21 | app.use('/api', EndPoints)
22 |
23 | app.use('/data', express.static(path.join(__dirname, '../data')))
24 |
25 | app.get('*', (req: Request, res: Response) => {
26 | res.sendFile(path.resolve('..', 'client', 'build', 'index.html'))
27 | })
28 |
29 | app.listen(config.port, async () => {
30 | await migrateSoundDatabase()
31 | await migrateUserDatabase()
32 | await twitchConnection.onStartConnect()
33 | console.log(`Server listening on port ${config.port}`)
34 | })
35 |
--------------------------------------------------------------------------------
/server/src/soundupload.ts:
--------------------------------------------------------------------------------
1 | import * as multer from 'multer'
2 | import * as uuid from 'uuid/v5'
3 |
4 | import config from './config'
5 |
6 | import { Request } from 'express'
7 | import { getFileExtension } from './utils'
8 |
9 | const soundStorage = multer.diskStorage({
10 | destination: function (req, file, cb) {
11 | cb(null, './data/sounds/')
12 | },
13 | filename: function (req: Request, file, cb) {
14 | const command = req.body.command
15 | const filename = uuid(command, config.uuidNameSpace)
16 | switch (getFileExtension(file.originalname)) {
17 | case 'mp3':
18 | cb(null, `${filename}.mp3`)
19 | break
20 | default:
21 | cb(null, `${filename}`)
22 | }
23 | }
24 | })
25 |
26 | const soundFilter = (req, file, cb) => {
27 | if (file.mimetype === 'audio/mpeg' || file.mimetype === 'audio/mp3') {
28 | cb(null, true)
29 | }
30 | else {
31 | cb(null, false)
32 | }
33 | }
34 |
35 | export default multer({
36 | storage: soundStorage,
37 | fileFilter: soundFilter,
38 | limits: { fileSize: 1024 * 1024 * 5 }
39 | })
40 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/index.ts:
--------------------------------------------------------------------------------
1 | import DashboardMain from './DashboardMain'
2 | import Navigation from './Navigation'
3 | import HomePage from './HomePage'
4 | import SoundPage from './SoundPage'
5 | import Layout from './Layout'
6 | import SoundTable from './SoundTable'
7 | import IconHelper from './IconHelper'
8 | import ConnectionPage from './ConnectionPage'
9 | import ConnectionForm from './ConnectionForm'
10 | import ConnectedCard from './ConnectedCard'
11 | import NewSoundDialog from './dialogs/NewSoundDialog'
12 | import EditSoundDialog from './dialogs/EditSoundDialog'
13 | import UserPage from './UserPage'
14 | import UserTable from './UserTable'
15 | import NewUserDialog from './dialogs/NewUserDialog'
16 | import EditUserDialog from './dialogs/EditUserDialog'
17 |
18 | export {
19 | Navigation,
20 | DashboardMain,
21 | HomePage,
22 | SoundPage,
23 | Layout,
24 | SoundTable,
25 | NewSoundDialog,
26 | IconHelper,
27 | ConnectionPage,
28 | ConnectionForm,
29 | ConnectedCard,
30 | EditSoundDialog,
31 | UserPage,
32 | UserTable,
33 | NewUserDialog,
34 | EditUserDialog
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export type AccessLevel = 'ALL' | 'VIP' | 'MOD' | 'SUB'
2 | export type UserFlags = 'ban' | 'all-access'
3 |
4 | export interface Sound {
5 | id: string,
6 | access: AccessLevel,
7 | command: string,
8 | path: string,
9 | level: number
10 | }
11 |
12 | export interface NewSound {
13 | access: AccessLevel,
14 | command: string,
15 | file: File,
16 | level: number
17 | }
18 |
19 | export interface NewSoundNoUpload {
20 | access: AccessLevel,
21 | command: string,
22 | level: number
23 | }
24 |
25 | export interface EditSound {
26 | id: string,
27 | access: AccessLevel,
28 | command: string,
29 | file: File | null,
30 | path: string,
31 | level: number
32 | }
33 |
34 | export interface TwitchUser {
35 | username: string | null,
36 | oauth: string | null,
37 | channels: string[] | null
38 | }
39 |
40 | export interface User {
41 | id: string,
42 | username: string,
43 | flags: UserFlags[]
44 | }
45 |
46 | export interface NewUser {
47 | username: string,
48 | flags: UserFlags[]
49 | }
50 |
51 | export interface Config {
52 | socketUrl: string
53 | }
54 |
--------------------------------------------------------------------------------
/client/src/theme/palette.ts:
--------------------------------------------------------------------------------
1 | const white = '#fff'
2 | export const customColors = {
3 | danger: {
4 | contrastText: white,
5 | main: '#ED4740',
6 | light: '#FEF6F6',
7 | dark: '#BF0E08'
8 | },
9 | warning: {
10 | contrastText: white,
11 | main: '#FFB822',
12 | light: '#FDF8F3',
13 | dark: '#95591E'
14 | },
15 | success: {
16 | contrastText: white,
17 | main: '#00aa45',
18 | light: '#00C851',
19 | dark: '#007E33'
20 | },
21 | info: {
22 | contrastText: white,
23 | main: '#1070CA',
24 | light: '#F1FBFC',
25 | dark: '#007489'
26 | }
27 | }
28 |
29 | export default {
30 | common: {
31 | white,
32 | black: '#000'
33 | },
34 | primary: {
35 | contrastText: white,
36 | main: '#5e35b1',
37 | light: '#7e5dc0',
38 | dark: '#41257b'
39 | },
40 | secondary: {
41 | contrastText: white,
42 | main: '#3949ab',
43 | light: '#606dbb',
44 | dark: '#273377'
45 | },
46 | text: {
47 | primary: '#12161B',
48 | secondary: '#66788A',
49 | disabled: '#A6B1BB'
50 | },
51 | background: {
52 | paper: '#fff',
53 | default: '#f8fafc'
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Twitch Play Sounds
2 |
3 | Twitch play sounds is a twitch-chatbot, that allows streamer to easily add and remove commands which plays certain sound effect to stream.
4 |
5 | ## Requirements:
6 |
7 | Project local json as database so no external database is needed.
8 |
9 | * Node.JS (tested on LTS 10.16.0)
10 | * NPM
11 |
12 | ## Setup:
13 |
14 | 1. Clone this repo.
15 | 2. Run `npm intall` in both `/client` and `/server`.
16 | 3. Rename `.env.example` to `.env` and change settings on your needs.
17 | 4. Run `npm run production` in `/server`.
18 | * When running second time, only `npm run server` is needed.
19 |
20 | ## Usage:
21 |
22 | Application opens (by default) to port 8080 so by going to `http://localhost:8080` you can access dashboard. After you get access to dashboard, go to `connections` tab and connect your twitch user into dashboard. When it's successfully connected, go to sounds manager and start adding sound effects. NOTE that only `.mp3` files are currently supported! After you have added command, open `http://localhost:8080/player` as tab or in OBS Browser.
23 |
24 | ## Images:
25 |
26 | 
27 | 
28 | 
--------------------------------------------------------------------------------
/client/src/components/dashboard/IconHelper.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { AccessLevel } from '../../types'
3 | import { Twitch, Sword, Crown, StarFourPoints } from 'mdi-material-ui'
4 | import { Tooltip } from '@material-ui/core'
5 |
6 | interface Props {
7 | access: AccessLevel
8 | }
9 |
10 | export default ({
11 | access
12 | }: Props) => {
13 | switch (access) {
14 | case 'ALL': return (
15 |
16 |
17 |
18 | )
19 | case 'SUB': return (
20 |
21 |
22 |
23 | )
24 | case 'MOD': return (
25 |
26 |
27 |
28 | )
29 | case 'VIP': return (
30 |
31 |
32 |
33 | )
34 | default: return (
35 |
36 |
37 |
38 | )
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/hooks/useUsers.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { User, NewUser } from '../types'
3 |
4 | import * as client from '../client'
5 |
6 | interface UserContext {
7 | users: User[],
8 | loading: boolean,
9 | addUser: (set: NewUser) => Promise,
10 | editUser: (set: User) => Promise,
11 | deleteUser: (id: string) => Promise
12 | }
13 |
14 | export const useUsers = (): UserContext => {
15 | const [users, setUsers] = useState([])
16 | const [loading, setLoading] = useState(true)
17 |
18 | const getUsers = () => client.fetchUsers()
19 | .then(users => {
20 | setUsers(users)
21 | setLoading(false)
22 | })
23 |
24 | const addUser = (
25 | user: NewUser
26 | ): Promise => client.addUsers(user)
27 | .then(s => setUsers([ ...users, s ]))
28 |
29 | const deleteUser = (id: string) => client.deleteUsers(id)
30 | .then(users => setUsers(users))
31 |
32 | const editUser = (user: User) => client.editUser(user.id, user)
33 | .then(u => setUsers(users.map(i => i.id === u.id ? u : i)))
34 |
35 | useEffect(() => {
36 | getUsers()
37 | }, [])
38 |
39 | return {
40 | users,
41 | loading,
42 | addUser,
43 | editUser,
44 | deleteUser
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/ConnectionPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Layout, ConnectionForm, ConnectedCard } from '.'
3 | import { customColors } from '../../theme'
4 | import clsx from 'clsx'
5 | import { useAppContext } from '../../hooks'
6 |
7 | import {
8 | createStyles,
9 | makeStyles,
10 | Theme,
11 | Chip
12 | } from '@material-ui/core'
13 |
14 | const useStyles = makeStyles((theme: Theme) =>
15 | createStyles({
16 | chip: {
17 | marginTop: theme.spacing(1),
18 | color: theme.palette.common.white
19 | },
20 | connected: {
21 | backgroundColor: customColors.success.main
22 | },
23 | disconnected: {
24 | backgroundColor: customColors.danger.main
25 | },
26 | loading: {
27 | marginTop: theme.spacing(1)
28 | }
29 | })
30 | )
31 |
32 | export default () => {
33 | const classes = useStyles()
34 | const { user, auth, login, logout } = useAppContext()
35 |
36 | return (
37 | <>
38 |
39 |
46 |
47 |
48 | {!auth || !user ? (
49 |
50 | ) : (
51 |
52 | )}
53 |
54 | >
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/client/src/theme/typography.ts:
--------------------------------------------------------------------------------
1 | import palette from './palette'
2 | import { TypographyOptions } from '@material-ui/core/styles/createTypography'
3 |
4 | const typography: TypographyOptions = {
5 | h1: {
6 | fontWeight: 500,
7 | fontSize: '35px',
8 | letterSpacing: '-0.24px',
9 | lineHeight: '40px'
10 | },
11 | h2: {
12 | fontWeight: 500,
13 | fontSize: '29px',
14 | letterSpacing: '-0.24px',
15 | lineHeight: '32px'
16 | },
17 | h3: {
18 | fontWeight: 500,
19 | fontSize: '24px',
20 | letterSpacing: '-0.06px',
21 | lineHeight: '28px'
22 | },
23 | h4: {
24 | fontWeight: 500,
25 | fontSize: '20px',
26 | letterSpacing: '-0.06px',
27 | lineHeight: '24px'
28 | },
29 | h5: {
30 | fontWeight: 500,
31 | fontSize: '16px',
32 | letterSpacing: '-0.05px',
33 | lineHeight: '20px'
34 | },
35 | h6: {
36 | fontWeight: 500,
37 | fontSize: '14px',
38 | letterSpacing: '-0.05px',
39 | lineHeight: '20px'
40 | },
41 | subtitle1: {
42 | fontSize: '16px',
43 | letterSpacing: '-0.05px',
44 | lineHeight: '25px'
45 | },
46 | subtitle2: {
47 | fontSize: '14px',
48 | letterSpacing: 0,
49 | lineHeight: '16px'
50 | },
51 | body1: {
52 | color: palette.text.primary,
53 | fontSize: '14px',
54 | letterSpacing: '-0.05px',
55 | lineHeight: '21px'
56 | },
57 | body2: {
58 | color: palette.text.primary,
59 | fontSize: '12px',
60 | letterSpacing: '-0.04px',
61 | lineHeight: '14px'
62 | },
63 | button: {
64 | fontSize: '14px'
65 | },
66 | caption: {
67 | color: palette.text.secondary,
68 | fontSize: '12px',
69 | letterSpacing: '0.3px',
70 | lineHeight: '16px'
71 | }
72 | }
73 |
74 | export default typography
75 |
--------------------------------------------------------------------------------
/client/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const HtmlWebpackPlugin = require('html-webpack-plugin')
3 | const CleanWebpackPlugin = require('clean-webpack-plugin')
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin')
5 | const CopyWebpackPlugin = require('copy-webpack-plugin')
6 |
7 | module.exports = {
8 | mode: 'production',
9 | entry: [
10 | './src/index.tsx'
11 | ],
12 | resolve: {
13 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json']
14 | },
15 | output: {
16 | filename: 'bundle.[hash].js',
17 | path: path.resolve(__dirname, 'build')
18 | },
19 | module: {
20 | rules: [
21 | {
22 | exclude: /node_modules/,
23 | test: /\.[j|t]sx?$/,
24 | loader: 'babel-loader',
25 | options: {
26 | compact: true
27 | }
28 | }, {
29 | test: /\.(woff|woff2|eot|ttf|otf|svg|png)$/,
30 | loader: 'file-loader'
31 | }, {
32 | test: /\.css$/,
33 | use: [
34 | 'style-loader',
35 | 'css-loader'
36 | ]
37 | }, {
38 | test: /\.less$/,
39 | use: [
40 | 'style-loader',
41 | 'css-loader',
42 | 'less-loader',
43 | 'import-glob'
44 | ]
45 | }
46 | ]
47 | },
48 | plugins: [
49 | new CleanWebpackPlugin({
50 | cleanOnceBeforeBuildPatterns: ['build/*']
51 | }),
52 | new HtmlWebpackPlugin({
53 | inject: true,
54 | hash: true,
55 | template: './public/index.html',
56 | filename: 'index.html'
57 | }),
58 | new MiniCssExtractPlugin({
59 | filename: 'styles.[hash].css'
60 | }),
61 | new CopyWebpackPlugin([{
62 | from: path.resolve(__dirname, 'public'),
63 | to: path.resolve(__dirname, 'build')
64 | }])
65 | ]
66 | }
67 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/UserPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Layout, UserTable, NewUserDialog, EditUserDialog } from '.'
3 | import { User } from '../../types'
4 | import { Plus } from 'mdi-material-ui'
5 | import { useUsers } from '../../hooks'
6 |
7 | import {
8 | createStyles,
9 | Theme,
10 | makeStyles,
11 | Tooltip,
12 | Fab
13 | } from '@material-ui/core'
14 |
15 | const useStyles = makeStyles((theme: Theme) =>
16 | createStyles({
17 | action: {
18 | position: 'fixed',
19 | bottom: '0',
20 | right: '0',
21 | margin: theme.spacing(3.5)
22 | }
23 | })
24 | )
25 |
26 | export default () => {
27 | const classes = useStyles()
28 | const [ isNewDialogOpen, setIsNewDialogOpen ] = useState(false)
29 | const [ editableUser, setEditableUser ] = useState(null)
30 | const {
31 | users,
32 | loading,
33 | addUser,
34 | deleteUser,
35 | editUser
36 | } = useUsers()
37 |
38 | return (
39 | <>
40 |
41 | setEditableUser(user)}
46 | />
47 |
48 |
49 |
50 | setIsNewDialogOpen(true)}
53 | >
54 |
55 |
56 |
57 |
58 | setIsNewDialogOpen(false)}
61 | onAdd={addUser}
62 | />
63 | setEditableUser(null)}
66 | user={editableUser}
67 | onEdit={editUser}
68 | setUser={setEditableUser}
69 | />
70 | >
71 | )
72 | }
73 |
--------------------------------------------------------------------------------
/client/src/hooks/useAppContext.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useContext,
3 | useEffect,
4 | useState,
5 | ReactNode,
6 | createContext
7 | } from 'react'
8 |
9 | import * as client from '../client'
10 | import openSocket from 'socket.io-client'
11 | import { TwitchUser } from '../types'
12 | import config from '../config'
13 |
14 | interface Context {
15 | user: null | TwitchUser,
16 | update: () => void,
17 | logout: () => void,
18 | login: (user: TwitchUser) => void,
19 | auth: boolean
20 | }
21 |
22 | export const __AppContext = createContext({
23 | user: null,
24 | update: () => {},
25 | logout: () => {},
26 | login: () => {},
27 | auth: false
28 | })
29 |
30 | interface Props {
31 | children: ReactNode
32 | }
33 |
34 | export const AppProvider = ({ children }: Props) => {
35 | const [user, setUser] = useState(null)
36 | const [auth, setAuth] = useState(false)
37 |
38 | const subscribeToSocket = () => {
39 | const socket = openSocket(config.socketUrl)
40 |
41 | socket.on('UPDATE_TWITCH', (
42 | auth: boolean
43 | ) => setAuth(auth))
44 | }
45 |
46 | const checkAuth = () => client.checkAuth()
47 | .then(setAuth)
48 |
49 | const update = () => {
50 | client.fetchUser()
51 | .then(user => setUser(user))
52 | subscribeToSocket()
53 | checkAuth()
54 | }
55 |
56 | const login = (newUser: TwitchUser) => {
57 | client.addUser(newUser)
58 | .then(() => {
59 | setUser(newUser)
60 | setAuth(true)
61 | })
62 | }
63 |
64 | const logout = () => client.logOut()
65 | .then(() => setUser(null))
66 |
67 | useEffect(update, [])
68 |
69 | return (
70 | <__AppContext.Provider
71 | value={{
72 | user,
73 | update,
74 | login,
75 | logout,
76 | auth
77 | }}>
78 | {children}
79 |
80 | )
81 | }
82 |
83 | export const useAppContext = (): Context => useContext(__AppContext)
84 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/SoundPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useSounds } from '../../hooks/useSounds'
3 | import { EditSound } from '../../types'
4 |
5 | import {
6 | Layout,
7 | SoundTable,
8 | NewSoundDialog,
9 | EditSoundDialog
10 | } from '.'
11 |
12 | import {
13 | Tooltip,
14 | Fab,
15 | createStyles,
16 | Theme,
17 | makeStyles
18 | } from '@material-ui/core'
19 |
20 | import { Plus } from 'mdi-material-ui'
21 |
22 | const useStyles = makeStyles((theme: Theme) =>
23 | createStyles({
24 | action: {
25 | position: 'fixed',
26 | bottom: '0',
27 | right: '0',
28 | margin: theme.spacing(3.5)
29 | }
30 | })
31 | )
32 |
33 | export default () => {
34 | const classes = useStyles()
35 | const {
36 | sounds,
37 | loading,
38 | addSound,
39 | editSound,
40 | deleteSound
41 | } = useSounds()
42 |
43 | const [ isNewDialogOpen, setIsNewDialogOpen ] = useState(false)
44 | const [ editableSound, setEditableSound ] = useState(null)
45 |
46 | return (
47 |
48 |
54 |
55 |
56 | setIsNewDialogOpen(true)}
59 | >
60 |
61 |
62 |
63 |
64 | setIsNewDialogOpen(false)}
67 | onAdd={addSound}
68 | />
69 | setEditableSound(null)}
72 | sound={editableSound}
73 | onEdit={editSound}
74 | setSound={setEditableSound}
75 | />
76 |
77 | )
78 | }
79 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitch-play-sound-server",
3 | "version": "1.0.0",
4 | "description": "twitch-play-sound-server",
5 | "author": "Jesse Båtman (http://jessebatman.fi)",
6 | "main": "src/server.ts",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/Morabotti/twitch-play-sound.git"
10 | },
11 | "scripts": {
12 | "start": "node --inspect=5858 -r ts-node/register ./src/index.ts",
13 | "dev": "nodemon",
14 | "build": "tsc",
15 | "build:start": "tsc && node dist/index.js",
16 | "production": "cd ../client & npm run build && cd ../server & npm run build:start",
17 | "server": "node dist/index.js",
18 | "lint": "eslint \"src/**/*.{ts,tsx}\"",
19 | "format": "eslint \"src/**/*.{ts,tsx}\" --fix"
20 | },
21 | "nodemonConfig": {
22 | "ignore": [
23 | "**/*.test.ts",
24 | "**/*.spec.ts",
25 | ".git",
26 | "node_modules"
27 | ],
28 | "watch": [
29 | "src"
30 | ],
31 | "exec": "npm start",
32 | "ext": "ts"
33 | },
34 | "license": "MIT",
35 | "dependencies": {
36 | "body-parser": "^1.18.3",
37 | "dotenv": "^7.0.0",
38 | "eslint": "5.16.0",
39 | "eslint-config-jubic-typescript": "1.0.0",
40 | "eslint-plugin-import": "^2.18.0",
41 | "eslint-plugin-node": "^9.1.0",
42 | "express": "^4.16.4",
43 | "jsonfile": "^5.0.0",
44 | "moment": "^2.24.0",
45 | "multer": "^1.4.1",
46 | "socket.io": "^2.2.0",
47 | "tmi.js": "^1.4.5",
48 | "ts-node": "^8.0.3",
49 | "typescript": "^3.3.3333",
50 | "uuid": "^3.3.2"
51 | },
52 | "devDependencies": {
53 | "@types/body-parser": "^1.17.0",
54 | "@types/dotenv": "^6.1.0",
55 | "@types/express": "^4.16.1",
56 | "@types/jsonfile": "^5.0.0",
57 | "@types/moment": "^2.13.0",
58 | "@types/mongoose": "^5.3.26",
59 | "@types/multer": "^1.3.7",
60 | "@types/node": "^11.11.3",
61 | "@types/socket.io": "^2.1.2",
62 | "@types/tmi.js": "^1.4.0",
63 | "@types/uuid": "^3.4.5",
64 | "csgo-gsi-types": "^1.0.5",
65 | "nodemon": "^1.18.9",
66 | "ts-essentials": "^2.0.7"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/client/src/hooks/useSounds.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react'
2 | import { Sound, NewSound, EditSound } from '../types'
3 |
4 | import * as client from '../client'
5 |
6 | interface SoundContext {
7 | sounds: Sound[],
8 | loading: boolean,
9 | addSound: (sound: NewSound) => Promise,
10 | editSound: (sound: EditSound) => Promise,
11 | deleteSound: (id: string) => void
12 | }
13 |
14 | export const useSounds = (): SoundContext => {
15 | const [sounds, setSounds] = useState([])
16 | const [loading, setLoading] = useState(true)
17 |
18 | const getSounds = () => client.fetchSounds()
19 | .then(sounds => {
20 | setSounds(sounds)
21 | setLoading(false)
22 | })
23 |
24 | const addSound = (sound: NewSound): Promise => {
25 | const form = new FormData()
26 | form.append('access', sound.access)
27 | form.append('command', sound.command)
28 | form.append('sound', sound.file)
29 | form.append('level', sound.level.toString())
30 | return client.addSound(form)
31 | .then(s => setSounds([...sounds, s]))
32 | }
33 |
34 | const deleteSound = (id: string) => {
35 | client.deleteSound(id)
36 | .then(sounds => setSounds(sounds))
37 | }
38 |
39 | const editSound = (sound: EditSound) => {
40 | if (sound.file) {
41 | const form = new FormData()
42 | form.append('access', sound.access)
43 | form.append('command', sound.command)
44 | form.append('level', sound.level.toString())
45 | form.append('sound', sound.file)
46 | return client.editSound(sound.id, form)
47 | .then(s => setSounds(sounds.map(i => i.id === s.id ? s : i)))
48 | }
49 | else {
50 | return client.editSoundNoUpload(sound.id, {
51 | access: sound.access,
52 | command: sound.command,
53 | level: sound.level
54 | })
55 | .then(s => setSounds(sounds.map(i => i.id === s.id ? s : i)))
56 | }
57 | }
58 |
59 | useEffect(() => {
60 | getSounds()
61 | }, [])
62 |
63 | return {
64 | sounds,
65 | loading,
66 | addSound,
67 | editSound,
68 | deleteSound
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/HomePage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Layout } from '.'
3 | import { Refresh } from 'mdi-material-ui'
4 |
5 | import {
6 | createStyles,
7 | Theme,
8 | makeStyles,
9 | Button,
10 | TextField
11 | } from '@material-ui/core'
12 |
13 | const useStyles = makeStyles((theme: Theme) =>
14 | createStyles({
15 | button: {
16 | margin: theme.spacing(1, 2, 0, 0)
17 | },
18 | rightIcon: {
19 | marginLeft: theme.spacing(1)
20 | },
21 | actionContainer: {
22 | marginBottom: theme.spacing(2)
23 | },
24 | url: {
25 | textDecoration: 'none'
26 | }
27 | })
28 | )
29 |
30 | export default () => {
31 | const [isDisabled, setDisabled] = useState(false)
32 | const classes = useStyles()
33 |
34 | return (
35 | <>
36 |
37 | ...
38 |
39 |
40 |
41 |
49 |
53 |
58 |
65 |
66 |
67 |
78 |
79 | >
80 | )
81 | }
82 |
--------------------------------------------------------------------------------
/client/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const fs = require('fs')
3 | const webpack = require('webpack')
4 | const HtmlWebpackPlugin = require('html-webpack-plugin')
5 | const CopyWebpackPlugin = require('copy-webpack-plugin')
6 |
7 | const directory = fs.realpathSync(process.cwd())
8 | const resolve = (relativePath) => path.resolve(directory, relativePath)
9 |
10 | module.exports = {
11 | mode: 'development',
12 | entry: {
13 | 'js': [
14 | require.resolve('react-hot-loader/patch'),
15 | resolve('src/index.tsx')
16 | ]
17 | },
18 | resolve: {
19 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.json']
20 | },
21 | output: {
22 | pathinfo: true,
23 | filename: '[name]/bundle.js',
24 | path: resolve('build'),
25 | publicPath: '/'
26 | },
27 | module: {
28 | rules: [
29 | {
30 | test: /\.[j|t]sx?$/,
31 | exclude: /node_modules/,
32 | loader: require.resolve('babel-loader'),
33 | options: {
34 | cacheDirectory: true,
35 | plugins: ['react-hot-loader/babel']
36 | }
37 | },
38 | {
39 | test: /\.less$/,
40 | use: [
41 | 'style-loader',
42 | 'css-loader',
43 | 'less-loader',
44 | 'import-glob'
45 | ]
46 | },
47 | {
48 | test: /\.css$/,
49 | use: [
50 | 'style-loader',
51 | 'css-loader'
52 | ]
53 | },
54 | {
55 | test: /\.(woff|woff2|eot|ttf|otf|svg|png)$/,
56 | use: ['file-loader']
57 | }
58 | ]
59 | },
60 | plugins: [
61 | new HtmlWebpackPlugin({
62 | inject: true,
63 | template: resolve('./public/index.html'),
64 | chunks: ['js']
65 | }),
66 | new webpack.HotModuleReplacementPlugin(),
67 | new CopyWebpackPlugin([{
68 | from: path.resolve(__dirname, 'public'),
69 | to: path.resolve(__dirname, 'build')
70 | }])
71 | ],
72 | devServer: {
73 | proxy: {
74 | '/api': {
75 | target: 'http://localhost:8080'
76 | },
77 | '/data': {
78 | target: 'http://localhost:8080'
79 | }
80 | },
81 | port: 8082,
82 | contentBase: resolve('./build'),
83 | hot: true,
84 | disableHostCheck: true,
85 | historyApiFallback: true
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twitch-play-sound-client",
3 | "version": "1.0.0",
4 | "description": "twitch-play-sound-client",
5 | "author": "Jesse Båtman (http://jessebatman.fi)",
6 | "main": "src/index.ts",
7 | "license": "MIT",
8 | "repository": {
9 | "type": "git",
10 | "url": "https://github.com/Morabotti/twitch-play-sound.git"
11 | },
12 | "scripts": {
13 | "start": "webpack-dev-server --config webpack.config.dev.js",
14 | "build": "webpack --mode production --config webpack.config.js",
15 | "lint": "eslint \"src/**/*.{ts,tsx}\"",
16 | "format": "eslint \"src/**/*.{ts,tsx}\" --fix",
17 | "types": "tsc",
18 | "test": "npm run lint && npm run types"
19 | },
20 | "devDependencies": {
21 | "@types/react": "16.8.18",
22 | "@types/react-dom": "16.8.4",
23 | "@types/react-router-dom": "^4.3.4",
24 | "@types/socket.io-client": "^1.4.32",
25 | "@types/webpack-env": "^1.14.0",
26 | "react-hot-loader": "4.12.6",
27 | "webpack-dev-server": "3.7.2"
28 | },
29 | "dependencies": {
30 | "@babel/core": "7.4.5",
31 | "@babel/plugin-proposal-class-properties": "7.4.4",
32 | "@babel/plugin-proposal-object-rest-spread": "7.4.4",
33 | "@babel/plugin-syntax-dynamic-import": "^7.2.0",
34 | "@babel/preset-env": "7.4.5",
35 | "@babel/preset-react": "7.0.0",
36 | "@babel/preset-typescript": "7.3.3",
37 | "@material-ui/core": "^4.2.0",
38 | "babel-loader": "8.0.6",
39 | "clean-webpack-plugin": "2.0.2",
40 | "clsx": "^1.0.4",
41 | "copy-webpack-plugin": "5.0.3",
42 | "css-loader": "2.1.0",
43 | "eslint": "5.16.0",
44 | "eslint-config-jubic-typescript": "1.0.0",
45 | "eslint-plugin-import": "^2.18.0",
46 | "eslint-plugin-node": "^9.1.0",
47 | "eslint-plugin-react": "^7.14.2",
48 | "file-loader": "3.0.1",
49 | "html-webpack-plugin": "3.2.0",
50 | "import-glob": "1.5.0",
51 | "less": "3.9.0",
52 | "less-loader": "4.1.0",
53 | "mdi-material-ui": "^6.1.0",
54 | "mini-css-extract-plugin": "0.6.0",
55 | "react": "16.8.6",
56 | "react-dom": "16.8.6",
57 | "react-router-dom": "^5.0.1",
58 | "socket.io-client": "^2.2.0",
59 | "style-loader": "0.23.1",
60 | "typescript": "3.5.3",
61 | "webpack": "4.32.1",
62 | "webpack-cli": "3.3.1",
63 | "whatwg-fetch": "^3.0.0"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/UserTable.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import clsx from 'clsx'
3 |
4 | import {
5 | User
6 | } from '../../types'
7 |
8 | import {
9 | Table,
10 | TableHead,
11 | TableCell,
12 | TableBody,
13 | TableRow,
14 | createStyles,
15 | makeStyles,
16 | Theme,
17 | LinearProgress,
18 | IconButton,
19 | Tooltip,
20 | Typography as T
21 | } from '@material-ui/core'
22 |
23 | import { DeleteOutline, Pencil } from 'mdi-material-ui'
24 |
25 | const useStyles = makeStyles((theme: Theme) =>
26 | createStyles({
27 | table: {
28 | minWidth: 650,
29 | marginTop: theme.spacing(1)
30 | },
31 | loading: {
32 | marginTop: theme.spacing(1)
33 | },
34 | small: {
35 | padding: theme.spacing(0.75)
36 | },
37 | iconOffset: {
38 | marginRight: theme.spacing(2)
39 | },
40 | accessText: {
41 | display: 'inline-flex',
42 | verticalAlign: 'super',
43 | marginLeft: theme.spacing(1.5)
44 | }
45 | })
46 | )
47 |
48 | interface Props {
49 | users: User[],
50 | loading: boolean,
51 | onDelete: (id: string) => void,
52 | onEdit: (user: User) => void
53 | }
54 |
55 | export default ({
56 | users,
57 | loading,
58 | onDelete,
59 | onEdit
60 | }: Props) => {
61 | const classes = useStyles()
62 |
63 | if (loading) {
64 | return (
65 |
66 | )
67 | }
68 |
69 | return (
70 |
71 |
72 |
73 | Username
74 | Permissions
75 | Actions
76 |
77 |
78 |
79 | {users.map(user => (
80 |
84 |
85 | {user.username}
86 |
87 |
88 | {user.flags.map(i => {i} )}
89 |
90 |
91 |
92 |
93 | onEdit(user)}
96 | >
97 |
98 |
99 |
100 |
101 | onDelete(user.id)}
104 | >
105 |
106 |
107 |
108 |
109 |
110 |
111 | ))}
112 |
113 |
114 | )
115 | }
116 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/ConnectionForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { customColors } from '../../theme'
3 | import { Eye, EyeOff } from 'mdi-material-ui'
4 | import { TwitchUser } from '../../types'
5 |
6 | import {
7 | createStyles,
8 | Theme,
9 | makeStyles,
10 | Button,
11 | TextField,
12 | FormControl,
13 | InputLabel,
14 | Input,
15 | InputAdornment,
16 | IconButton,
17 | Typography as T
18 | } from '@material-ui/core'
19 |
20 | const useStyles = makeStyles((theme: Theme) =>
21 | createStyles({
22 | text: {
23 | marginTop: theme.spacing(2)
24 | },
25 | error: {
26 | color: customColors.danger.main
27 | },
28 | link: {
29 | textDecoration: 'none',
30 | color: theme.palette.secondary.light,
31 | fontWeight: 700
32 | }
33 | })
34 | )
35 |
36 | interface Props {
37 | onAuth: (user: TwitchUser) => void
38 | }
39 |
40 | interface State {
41 | username: string,
42 | oauth: string,
43 | error: boolean,
44 | showKey: boolean
45 | }
46 |
47 | const getInitialState = (): State => ({
48 | username: '',
49 | oauth: '',
50 | error: false,
51 | showKey: false
52 | })
53 |
54 | export default ({
55 | onAuth
56 | }: Props) => {
57 | const [state, setState] = useState(getInitialState)
58 | const classes = useStyles()
59 |
60 | const handleSubmit = () => {
61 | onAuth({
62 | username: state.username,
63 | oauth: state.oauth,
64 | channels: [state.username]
65 | })
66 | setState(getInitialState)
67 | }
68 |
69 | return (
70 | <>
71 | setState({
75 | ...state,
76 | username: e.currentTarget.value
77 | })}
78 | fullWidth
79 | error={state.error}
80 | className={classes.text}
81 | />
82 |
83 | OAuth key
84 | setState({
90 | ...state,
91 | oauth: e.currentTarget.value
92 | })}
93 | endAdornment={
94 |
95 | setState({
98 | ...state,
99 | showKey: !state.showKey
100 | })}
101 | >
102 | {state.showKey ? : }
103 |
104 |
105 | }
106 | />
107 |
108 | Get OAauth key: Here
111 |
112 |
121 | >
122 | )
123 | }
124 |
--------------------------------------------------------------------------------
/server/src/users.ts:
--------------------------------------------------------------------------------
1 | import * as jsonfile from 'jsonfile'
2 | import * as uuid from 'uuid/v5'
3 |
4 | import config from './config'
5 |
6 | import { User, UserRequest } from './types'
7 |
8 | const fileUsers = './db/db-users.json'
9 |
10 | const readUsers = (): Promise => new Promise((resolve, reject) => {
11 | jsonfile.readFile(fileUsers, (err, mainObj: User[]) => {
12 | if (err) reject(err)
13 | resolve(mainObj)
14 | })
15 | })
16 |
17 | const setUsers = (
18 | data: User[]
19 | ) => new Promise((resolve, reject) => {
20 | jsonfile.writeFile(fileUsers, data, (err) => {
21 | if (err) reject(err)
22 | resolve()
23 | })
24 | })
25 |
26 | export const isUserTaken = async (
27 | username: string
28 | ): Promise => {
29 | if (username === '') {
30 | throw new Error('Given command is empty')
31 | }
32 |
33 | const users = await readUsers()
34 | const found = users.find(s => s.username === username)
35 | return found === undefined
36 | }
37 |
38 | export const findUserById = async (
39 | id: string
40 | ) => {
41 | if (id === '') {
42 | throw new Error('Given ID is empty')
43 | }
44 | const users = await readUsers()
45 | const found = users.find(s => s.id === id)
46 | if (!found) {
47 | throw new Error('User was not found')
48 | }
49 | return found
50 | }
51 |
52 | export const addUser = async (
53 | user: UserRequest
54 | ): Promise => {
55 | try {
56 | const isFree = await isUserTaken(user.username)
57 | if (isFree) {
58 | const users = await readUsers()
59 | const newUser: User = {
60 | ...user,
61 | id: uuid(user.username, config.uuidNameSpace)
62 | }
63 | await setUsers([ ...users, newUser ])
64 |
65 | return newUser
66 | }
67 | else {
68 | throw new Error('Sound command is already taken!')
69 | }
70 | }
71 | catch (err) {
72 | console.log(err)
73 | throw new Error(err)
74 | }
75 | }
76 |
77 | export const deleteUser = async (
78 | id: string
79 | ): Promise => {
80 | try {
81 | const oldUser = await findUserById(id)
82 | const userList = await readUsers()
83 |
84 | const updated = userList.filter(l => l.id !== oldUser.id)
85 | await setUsers(updated)
86 |
87 | return updated
88 | }
89 | catch (err) {
90 | console.log(err)
91 | throw new Error(err)
92 | }
93 | }
94 |
95 | export const updateUser = async (
96 | id: string,
97 | user: User
98 | ): Promise => {
99 | try {
100 | const oldUser = await findUserById(id)
101 | const userList = await readUsers()
102 |
103 | const isFree = await isUserTaken(user.username)
104 |
105 | if (oldUser.username !== user.username && !isFree) {
106 | throw new Error('This command is already taken')
107 | }
108 |
109 | const newUser: User = {
110 | id: oldUser.id,
111 | ...user
112 | }
113 |
114 | const updated = userList.map(l => l.id === newUser.id ? newUser : l)
115 | await setUsers(updated)
116 |
117 | return newUser
118 | }
119 | catch (err) {
120 | console.log(err)
121 | throw new Error(err)
122 | }
123 | }
124 |
125 | export const findUser = async (
126 | username: string | undefined
127 | ): Promise => {
128 | if (username === undefined) {
129 | return null
130 | }
131 |
132 | const users = await readUsers()
133 | const found = users.find(s => s.username.toLowerCase() === username.toLowerCase())
134 | if (!found) {
135 | return null
136 | }
137 |
138 | return found
139 | }
140 |
141 | export const fetchUsers = async (): Promise => readUsers()
142 | export const migrateUserDB = async () => setUsers([])
143 |
144 | export const migrateUserDatabase = async () => {
145 | try {
146 | await fetchUsers()
147 | }
148 | catch (e) {
149 | console.log('Migrating user DB')
150 | await migrateUserDB()
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/SoundTable.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { IconHelper } from '.'
3 | import clsx from 'clsx'
4 |
5 | import {
6 | Sound,
7 | EditSound
8 | } from '../../types'
9 |
10 | import {
11 | Table,
12 | TableHead,
13 | TableCell,
14 | TableBody,
15 | TableRow,
16 | createStyles,
17 | makeStyles,
18 | Theme,
19 | LinearProgress,
20 | IconButton,
21 | Tooltip,
22 | Typography as T
23 | } from '@material-ui/core'
24 |
25 | import { DeleteOutline, Play, Pencil } from 'mdi-material-ui'
26 |
27 | const useStyles = makeStyles((theme: Theme) =>
28 | createStyles({
29 | table: {
30 | minWidth: 650,
31 | marginTop: theme.spacing(1)
32 | },
33 | loading: {
34 | marginTop: theme.spacing(1)
35 | },
36 | small: {
37 | padding: theme.spacing(0.75)
38 | },
39 | iconOffset: {
40 | marginRight: theme.spacing(2)
41 | },
42 | accessText: {
43 | display: 'inline-flex',
44 | verticalAlign: 'super',
45 | marginLeft: theme.spacing(1.5)
46 | }
47 | })
48 | )
49 |
50 | interface Props {
51 | sounds: Sound[],
52 | loading: boolean,
53 | onDelete: (id: string) => void,
54 | onEdit: (sound: EditSound | null) => void
55 | }
56 |
57 | export default ({
58 | sounds,
59 | loading,
60 | onDelete,
61 | onEdit
62 | }: Props) => {
63 | const classes = useStyles()
64 |
65 | if (loading) {
66 | return (
67 |
68 | )
69 | }
70 |
71 | const _playSound = (sound: Sound) => () => {
72 | const fixed = `/${sound.path}`
73 | const audio = new Audio(fixed)
74 | const volumeLevel = (sound.level / 100)
75 | audio.volume = 0.75 * volumeLevel
76 | audio.play()
77 | }
78 |
79 | return (
80 |
81 |
82 |
83 | Command
84 | Access
85 | Actions
86 |
87 |
88 |
89 | {sounds.map(sound => (
90 |
94 |
95 | {sound.command}
96 |
97 |
98 |
99 | {sound.access}
100 |
101 |
102 |
103 |
104 |
108 |
109 |
110 |
111 |
112 | onEdit({
115 | ...sound,
116 | file: null
117 | })}
118 | >
119 |
120 |
121 |
122 |
123 | onDelete(sound.id)}
126 | >
127 |
128 |
129 |
130 |
131 |
132 |
133 | ))}
134 |
135 |
136 | )
137 | }
138 |
--------------------------------------------------------------------------------
/client/src/client.ts:
--------------------------------------------------------------------------------
1 | import { Sound, TwitchUser, NewSoundNoUpload, User, NewUser } from './types'
2 |
3 | const checkResponse = (res: Response): Response => {
4 | if (!res.ok) {
5 | throw Error(res.statusText)
6 | }
7 | return res
8 | }
9 |
10 | export const fetchSounds = (): Promise => fetch(
11 | '/api/sounds',
12 | {
13 | method: 'GET',
14 | headers: { 'Content-Type': 'application/json' }
15 | }
16 | )
17 | .then(checkResponse)
18 | .then((res) => res.json())
19 |
20 | export const checkAuth = (): Promise => fetch(
21 | '/api/twitch/auth',
22 | {
23 | method: 'GET',
24 | headers: { 'Content-Type': 'application/json' }
25 | }
26 | )
27 | .then(checkResponse)
28 | .then((res) => res.json())
29 |
30 | export const addSound = (
31 | data: FormData
32 | ): Promise => {
33 | const options = {
34 | method: 'POST',
35 | body: data,
36 | headers: { 'content-type': 'multipart/form-data' }
37 | }
38 |
39 | delete options.headers['content-type']
40 |
41 | return fetch('/api/sounds', options)
42 | .then(checkResponse)
43 | .then((res) => res.json())
44 | }
45 |
46 | export const editSound = (
47 | id: string,
48 | data: FormData
49 | ): Promise => {
50 | const options = {
51 | method: 'PUT',
52 | body: data,
53 | headers: { 'content-type': 'multipart/form-data' }
54 | }
55 |
56 | delete options.headers['content-type']
57 |
58 | return fetch('/api/sounds/' + id + '/upload', options)
59 | .then(checkResponse)
60 | .then((res) => res.json())
61 | }
62 |
63 | export const editSoundNoUpload = (
64 | id: string,
65 | sound: NewSoundNoUpload
66 | ): Promise => fetch(
67 | '/api/sounds/' + id + '/standard',
68 | {
69 | method: 'PUT',
70 | body: JSON.stringify(sound),
71 | headers: { 'Content-Type': 'application/json' }
72 | }
73 | )
74 | .then(checkResponse)
75 | .then((res) => res.json())
76 |
77 | export const deleteSound = (id: string): Promise => fetch(
78 | '/api/sounds/' + id,
79 | {
80 | method: 'DELETE',
81 | headers: { 'Content-Type': 'application/json' }
82 | }
83 | )
84 | .then(checkResponse)
85 | .then((res) => res.json())
86 |
87 | export const fetchUser = (): Promise => fetch(
88 | '/api/twitch',
89 | {
90 | method: 'GET',
91 | headers: { 'Content-Type': 'application/json' }
92 | }
93 | )
94 | .then(checkResponse)
95 | .then((res) => res.json())
96 |
97 | export const fetchUsers = (): Promise => fetch(
98 | '/api/users',
99 | {
100 | method: 'GET',
101 | headers: { 'Content-Type': 'application/json' }
102 | }
103 | )
104 | .then(checkResponse)
105 | .then((res) => res.json())
106 |
107 | export const addUsers = (
108 | user: NewUser
109 | ): Promise => fetch(
110 | '/api/users',
111 | {
112 | method: 'POST',
113 | body: JSON.stringify(user),
114 | headers: { 'Content-Type': 'application/json' }
115 | }
116 | )
117 | .then(checkResponse)
118 | .then((res) => res.json())
119 |
120 | export const deleteUsers = (id: string): Promise => fetch(
121 | '/api/users/' + id,
122 | {
123 | method: 'DELETE',
124 | headers: { 'Content-Type': 'application/json' }
125 | }
126 | )
127 | .then(checkResponse)
128 | .then((res) => res.json())
129 |
130 | export const editUser = (
131 | id: string,
132 | user: NewUser
133 | ): Promise => fetch(
134 | '/api/users/' + id,
135 | {
136 | method: 'PUT',
137 | body: JSON.stringify(user),
138 | headers: { 'Content-Type': 'application/json' }
139 | }
140 | )
141 | .then(checkResponse)
142 | .then((res) => res.json())
143 |
144 | export const addUser = (
145 | user: TwitchUser
146 | ): Promise => fetch(
147 | '/api/twitch',
148 | {
149 | method: 'POST',
150 | body: JSON.stringify(user),
151 | headers: { 'Content-Type': 'application/json' }
152 | }
153 | )
154 | .then(checkResponse)
155 |
156 | export const logOut = (): Promise => fetch(
157 | '/api/twitch/logout',
158 | {
159 | method: 'POST',
160 | headers: { 'Content-Type': 'application/json' }
161 | }
162 | )
163 | .then(checkResponse)
164 |
--------------------------------------------------------------------------------
/server/src/datastore.ts:
--------------------------------------------------------------------------------
1 | import * as jsonfile from 'jsonfile'
2 | import * as fs from 'fs'
3 | import * as uuid from 'uuid/v5'
4 |
5 | import config from './config'
6 |
7 | import { Sound, SoundRequest } from './types'
8 | import { deleteFile as deleteUtil } from './utils'
9 |
10 | const fileSongs = './db/db-sounds.json'
11 |
12 | const readSounds = (): Promise => new Promise((resolve, reject) => {
13 | jsonfile.readFile(fileSongs, (err, mainObj: Sound[]) => {
14 | if (err) reject(err)
15 | resolve(mainObj)
16 | })
17 | })
18 |
19 | const setSounds = (
20 | data: Sound[]
21 | ) => new Promise((resolve, reject) => {
22 | jsonfile.writeFile(fileSongs, data, (err) => {
23 | if (err) reject(err)
24 | resolve()
25 | })
26 | })
27 |
28 | export const deleteFile = (
29 | path: string
30 | ): Promise => new Promise((resolve, reject) => {
31 | try {
32 | resolve(fs.unlinkSync(path))
33 | }
34 | catch (err) {
35 | console.log(err)
36 | reject(err)
37 | }
38 | })
39 |
40 | export const isCommandFree = async (
41 | command: string
42 | ): Promise => {
43 | if (command === '' || command === '!') {
44 | throw new Error('Given command is empty')
45 | }
46 | const songs = await readSounds()
47 | const found = songs.find(s => s.command === command)
48 | return found === undefined
49 | }
50 |
51 | export const findSoundById = async (
52 | id: string
53 | ) => {
54 | if (id === '') {
55 | throw new Error('Given ID is empty')
56 | }
57 | const songs = await readSounds()
58 | const found = songs.find(s => s.id === id)
59 | if (!found) {
60 | throw new Error('Sound was not found')
61 | }
62 | return found
63 | }
64 |
65 | export const addSound = async (
66 | sound: SoundRequest
67 | ): Promise => {
68 | try {
69 | const isFree = await isCommandFree(sound.command)
70 | if (isFree) {
71 | const sounds = await readSounds()
72 | const newSound: Sound = {
73 | ...sound,
74 | id: uuid(sound.command, config.uuidNameSpace)
75 | }
76 | await setSounds([ ...sounds, newSound ])
77 |
78 | return newSound
79 | }
80 | else {
81 | throw new Error('Sound command is already taken!')
82 | }
83 | }
84 | catch (err) {
85 | console.log(err)
86 | throw new Error(err)
87 | }
88 | }
89 |
90 | export const updateSound = async (
91 | id: string,
92 | sound: SoundRequest,
93 | hasNewSound: boolean
94 | ): Promise => {
95 | try {
96 | const oldSound = await findSoundById(id)
97 | const soundList = await readSounds()
98 |
99 | const isFree = await isCommandFree(sound.command)
100 |
101 | if (sound.command !== oldSound.command && !isFree) {
102 | if (hasNewSound) {
103 | deleteUtil(sound.path)
104 | }
105 | throw new Error('This command is already taken')
106 | }
107 | else {
108 | if (hasNewSound) {
109 | deleteUtil(oldSound.path)
110 | }
111 | }
112 |
113 | const newSound: Sound = {
114 | id: oldSound.id,
115 | ...sound
116 | }
117 |
118 | const updated = soundList.map(l => l.id === newSound.id ? newSound : l)
119 | await setSounds(updated)
120 |
121 | return newSound
122 | }
123 | catch (err) {
124 | console.log(err)
125 | throw new Error(err)
126 | }
127 | }
128 |
129 | export const deleteSound = async (
130 | id: string
131 | ): Promise => {
132 | try {
133 | const oldSound = await findSoundById(id)
134 | const soundList = await readSounds()
135 |
136 | await deleteFile(oldSound.path)
137 | const updated = soundList.filter(l => l.id !== oldSound.id)
138 | await setSounds(updated)
139 |
140 | return updated
141 | }
142 | catch (err) {
143 | console.log(err)
144 | throw new Error(err)
145 | }
146 | }
147 |
148 | export const fetchSounds = async (): Promise => readSounds()
149 | export const migrateSoundDB = async () => setSounds([])
150 |
151 | export const migrateSoundDatabase = async () => {
152 | try {
153 | await fetchSounds()
154 | }
155 | catch (e) {
156 | console.log('Migrating sound DB')
157 | await migrateSoundDB()
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/dialogs/EditUserDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction, Fragment } from 'react'
2 |
3 | import { UserFlags, User } from '../../../types'
4 | import userFlags from '../../../enums/userFlags'
5 | import { customColors } from '../../../theme'
6 |
7 | import {
8 | Dialog,
9 | DialogActions,
10 | DialogTitle,
11 | DialogContent,
12 | Button,
13 | TextField,
14 | createStyles,
15 | makeStyles,
16 | Theme,
17 | FormControl,
18 | InputLabel,
19 | Select,
20 | Input,
21 | MenuItem,
22 | Chip
23 | } from '@material-ui/core'
24 |
25 | const useStyles = makeStyles((theme: Theme) =>
26 | createStyles({
27 | text: {
28 | margin: theme.spacing(2, 0),
29 | width: '100%'
30 | },
31 | error: {
32 | color: customColors.danger.main
33 | },
34 | input: {
35 | display: 'none'
36 | },
37 | icon: {
38 | marginLeft: theme.spacing(1)
39 | },
40 | replay: {
41 | marginRight: theme.spacing(2)
42 | },
43 | menuitem: {
44 | verticalAlign: 'middle'
45 | },
46 | menuItemText: {
47 | display: 'inline-flex',
48 | verticalAlign: 'super',
49 | fontWeight: 400,
50 | marginLeft: theme.spacing(1)
51 | },
52 | chip: {
53 | margin: theme.spacing(0, 0.5),
54 | cursor: 'pointer'
55 | },
56 | chips: {
57 | display: 'flex',
58 | flexWrap: 'wrap',
59 | cursor: 'pointer'
60 | }
61 | })
62 | )
63 |
64 | const ITEM_HEIGHT = 48
65 | const ITEM_PADDING_TOP = 8
66 | const MenuProps = {
67 | PaperProps: {
68 | style: {
69 | maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
70 | width: 250
71 | }
72 | }
73 | }
74 |
75 | interface Props {
76 | isOpen: boolean,
77 | onClose: () => void,
78 | onEdit: (sound: User) => Promise,
79 | user: User | null,
80 | setUser: Dispatch>
81 | }
82 |
83 | export default ({
84 | isOpen,
85 | onClose,
86 | onEdit,
87 | user,
88 | setUser
89 | }: Props) => {
90 | const classes = useStyles()
91 |
92 | const _handleClose = () => {
93 | onClose()
94 | }
95 |
96 | const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => {
97 | if (user) {
98 | setUser({
99 | ...user,
100 | flags: e.target.value as UserFlags[]
101 | })
102 | }
103 | }
104 |
105 | const _handleEditUser = () => {
106 | if (user && user.username !== '') {
107 | onEdit(user)
108 | .then(() => onClose())
109 | .catch(() => onClose())
110 | }
111 | }
112 |
113 | if (user === null) {
114 | return
115 | }
116 |
117 | return (
118 |
174 | )
175 | }
176 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/dialogs/NewUserDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { UserFlags, NewUser } from '../../../types'
4 | import userFlags from '../../../enums/userFlags'
5 | import { customColors } from '../../../theme'
6 |
7 | import {
8 | Dialog,
9 | DialogActions,
10 | DialogTitle,
11 | DialogContent,
12 | Button,
13 | TextField,
14 | createStyles,
15 | makeStyles,
16 | Theme,
17 | FormControl,
18 | InputLabel,
19 | Select,
20 | Input,
21 | MenuItem,
22 | Chip
23 | } from '@material-ui/core'
24 |
25 | const useStyles = makeStyles((theme: Theme) =>
26 | createStyles({
27 | text: {
28 | margin: theme.spacing(2, 0),
29 | width: '100%'
30 | },
31 | error: {
32 | color: customColors.danger.main
33 | },
34 | input: {
35 | display: 'none'
36 | },
37 | icon: {
38 | marginLeft: theme.spacing(1)
39 | },
40 | replay: {
41 | marginRight: theme.spacing(2)
42 | },
43 | menuitem: {
44 | verticalAlign: 'middle'
45 | },
46 | menuItemText: {
47 | display: 'inline-flex',
48 | verticalAlign: 'super',
49 | fontWeight: 400,
50 | marginLeft: theme.spacing(1)
51 | },
52 | chip: {
53 | margin: theme.spacing(0, 0.5),
54 | cursor: 'pointer'
55 | },
56 | chips: {
57 | display: 'flex',
58 | flexWrap: 'wrap',
59 | cursor: 'pointer'
60 | }
61 | })
62 | )
63 |
64 | const ITEM_HEIGHT = 48
65 | const ITEM_PADDING_TOP = 8
66 | const MenuProps = {
67 | PaperProps: {
68 | style: {
69 | maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
70 | width: 250
71 | }
72 | }
73 | }
74 |
75 | interface Props {
76 | isOpen: boolean,
77 | onClose: () => void,
78 | onAdd: (sound: NewUser) => Promise
79 | }
80 |
81 | interface State {
82 | flags: UserFlags[],
83 | username: string,
84 | error: boolean
85 | }
86 |
87 | const getInitialState = (): State => ({
88 | username: '',
89 | flags: [],
90 | error: false
91 | })
92 |
93 | export default ({
94 | isOpen,
95 | onClose,
96 | onAdd
97 | }: Props) => {
98 | const classes = useStyles()
99 | const [state, setState] = useState(getInitialState)
100 |
101 | const _handleClose = () => {
102 | onClose()
103 | setState(getInitialState)
104 | }
105 |
106 | const handleChange = (e: React.ChangeEvent<{ value: unknown }>) => {
107 | setState({
108 | ...state,
109 | flags: e.target.value as UserFlags[]
110 | })
111 | }
112 |
113 | const _handleNewUser = () => {
114 | if (state.username !== '') {
115 | onAdd({
116 | username: state.username,
117 | flags: state.flags
118 | })
119 | .then(() => {
120 | onClose()
121 | setState(getInitialState)
122 | })
123 | .catch(() => {
124 | setState({
125 | ...state,
126 | error: true
127 | })
128 | })
129 | }
130 | }
131 |
132 | return (
133 |
190 | )
191 | }
192 |
--------------------------------------------------------------------------------
/server/src/endpoints.ts:
--------------------------------------------------------------------------------
1 |
2 | import * as bodyParser from 'body-parser'
3 |
4 | import { Request, Response, Router } from 'express'
5 | import { fetchSounds, addSound, deleteSound, updateSound, findSoundById } from './datastore'
6 | import { fetchUsers, addUser, deleteUser, updateUser } from './users'
7 | import { SoundRequest } from './types'
8 |
9 | import soundUpload from './soundupload'
10 | import twitchConnection from './twitch'
11 |
12 | const router = Router()
13 |
14 | router.use(bodyParser.urlencoded({ extended: false }))
15 | router.use(bodyParser.json())
16 |
17 | router.post('/sounds', soundUpload.single('sound'), async (req: Request, res: Response) => {
18 | try {
19 | const { access, command, level } = req.body
20 | const newSong = await addSound({
21 | access,
22 | command,
23 | path: req.file.path,
24 | level: Number(level)
25 | })
26 |
27 | return res.status(200).send(newSong)
28 | }
29 | catch (e) {
30 | return res
31 | .status(500)
32 | .send(e)
33 | }
34 | })
35 |
36 | router.get('/sounds', async (req: Request, res: Response) => {
37 | try {
38 | const sounds = await fetchSounds()
39 | return res.status(200).send(sounds)
40 | }
41 | catch (e) {
42 | return res
43 | .status(500)
44 | .send(e)
45 | }
46 | })
47 |
48 | router.delete('/sounds/:id', async (req: Request, res: Response) => {
49 | try {
50 | const sounds = await deleteSound(req.params.id)
51 | return res.status(200).send(sounds)
52 | }
53 | catch (e) {
54 | return res
55 | .status(500)
56 | .send(e)
57 | }
58 | })
59 |
60 | router.put('/sounds/:id/upload', soundUpload.single('sound'), async (req: Request, res: Response) => {
61 | try {
62 | const { access, command, level } = req.body
63 | const updated: SoundRequest = {
64 | access,
65 | command,
66 | path: req.file.path,
67 | level: Number(level)
68 | }
69 |
70 | const sound = await updateSound(req.params.id, updated, true)
71 | return res.status(200).send(sound)
72 | }
73 | catch (e) {
74 | return res
75 | .status(500)
76 | .send(e)
77 | }
78 | })
79 |
80 | router.put('/sounds/:id/standard', async (req: Request, res: Response) => {
81 | try {
82 | const { access, command, level } = req.body
83 | const oldSound = await findSoundById(req.params.id)
84 |
85 | const updated: SoundRequest = {
86 | access,
87 | command,
88 | path: oldSound.path,
89 | level: Number(level)
90 | }
91 |
92 | const sound = await updateSound(req.params.id, updated, false)
93 | return res.status(200).send(sound)
94 | }
95 | catch (e) {
96 | return res
97 | .status(500)
98 | .send(e)
99 | }
100 | })
101 |
102 | router.get('/twitch/auth', async (req: Request, res: Response) => {
103 | const auth = await twitchConnection.isAuth()
104 | return res.status(200).send(auth)
105 | })
106 |
107 | router.post('/twitch/logout', async (req: Request, res: Response) => {
108 | const auth = await twitchConnection.disconnect()
109 | return res.status(200).send(auth)
110 | })
111 |
112 | router.get('/twitch', (req: Request, res: Response) => {
113 | try {
114 | const config = twitchConnection.getConfig()
115 | return res.status(200).send(config)
116 | }
117 | catch (e) {
118 | return res
119 | .status(500)
120 | .send(e)
121 | }
122 | })
123 |
124 | router.post('/twitch', async (req: Request, res: Response) => {
125 | try {
126 | const config = await twitchConnection.updateConfig({
127 | username: req.body.username,
128 | oauth: req.body.oauth,
129 | channels: req.body.channels
130 | })
131 | return res.status(200).send(config)
132 | }
133 | catch (e) {
134 | return res
135 | .status(500)
136 | .send(e)
137 | }
138 | })
139 |
140 | router.get('/users', async (req: Request, res: Response) => {
141 | try {
142 | const users = await fetchUsers()
143 | return res.status(200).send(users)
144 | }
145 | catch (e) {
146 | return res
147 | .status(500)
148 | .send(e)
149 | }
150 | })
151 |
152 | router.post('/users', async (req: Request, res: Response) => {
153 | try {
154 | const { username, flags } = req.body
155 | const user = await addUser({ username, flags })
156 | return res.status(200).send(user)
157 | }
158 | catch (e) {
159 | return res
160 | .status(500)
161 | .send(e)
162 | }
163 | })
164 |
165 | router.put('/users/:id', async (req: Request, res: Response) => {
166 | try {
167 | const { username, flags, id } = req.body
168 | const user = await updateUser(req.params.id, { id, username, flags })
169 | return res.status(200).send(user)
170 | }
171 | catch (e) {
172 | return res
173 | .status(500)
174 | .send(e)
175 | }
176 | })
177 |
178 | router.delete('/users/:id', async (req: Request, res: Response) => {
179 | try {
180 | const users = await deleteUser(req.params.id)
181 | return res.status(200).send(users)
182 | }
183 | catch (e) {
184 | return res
185 | .status(500)
186 | .send(e)
187 | }
188 | })
189 |
190 | export default router
191 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Home, Menu, MusicNote, Fire, Account } from 'mdi-material-ui'
3 | import { Switch, Route, Link } from 'react-router-dom'
4 | import { useRouter } from '../../hooks'
5 | import clsx from 'clsx'
6 |
7 | import HomePage from './HomePage'
8 | import SoundPage from './SoundPage'
9 | import ConnectionPage from './ConnectionPage'
10 | import UserPage from './UserPage'
11 |
12 | import {
13 | AppBar,
14 | Toolbar,
15 | IconButton,
16 | Typography as T,
17 | Drawer,
18 | List,
19 | ListItem,
20 | ListItemText,
21 | ListItemIcon,
22 | makeStyles,
23 | createStyles,
24 | Theme
25 | } from '@material-ui/core'
26 |
27 | const routes = [
28 | {
29 | name: 'Home',
30 | path: '/dashboard',
31 | icon: Home,
32 | component: HomePage
33 | },
34 | {
35 | name: 'Sound Manager',
36 | path: '/dashboard/sounds',
37 | icon: MusicNote,
38 | component: SoundPage
39 | },
40 | {
41 | name: 'Users',
42 | path: '/dashboard/users',
43 | icon: Account,
44 | component: UserPage
45 | },
46 | {
47 | name: 'Connections',
48 | path: '/dashboard/connection',
49 | icon: Fire,
50 | component: ConnectionPage
51 | }
52 | ]
53 |
54 | const drawerWidth = 240
55 |
56 | const useStyles = makeStyles((theme: Theme) =>
57 | createStyles({
58 | root: {
59 | display: 'flex'
60 | },
61 | appBar: {
62 | backgroundColor: theme.palette.primary.main
63 | },
64 | menuButton: {
65 | marginLeft: theme.spacing(-0.75),
66 | marginRight: theme.spacing(1)
67 | },
68 | title: {
69 | flexGrow: 1
70 | },
71 | iconButton: {
72 | marginRight: theme.spacing(-0.75),
73 | marginLeft: theme.spacing(1)
74 | },
75 | drawer: {
76 | width: drawerWidth,
77 | flexShrink: 0,
78 | whiteSpace: 'nowrap'
79 | },
80 | drawerOpen: {
81 | width: drawerWidth,
82 | transition: theme.transitions.create('width', {
83 | easing: theme.transitions.easing.sharp,
84 | duration: theme.transitions.duration.enteringScreen
85 | })
86 | },
87 | drawerClose: {
88 | transition: theme.transitions.create('width', {
89 | easing: theme.transitions.easing.sharp,
90 | duration: theme.transitions.duration.leavingScreen
91 | }),
92 | overflowX: 'hidden',
93 | width: theme.spacing(7),
94 | [theme.breakpoints.up('sm')]: {
95 | width: theme.spacing(8)
96 | }
97 | },
98 | toolbar: {
99 | display: 'flex',
100 | alignItems: 'center',
101 | justifyContent: 'flex-end',
102 | padding: '0 8px',
103 | ...theme.mixins.toolbar
104 | },
105 | drawerSelect: {
106 | borderLeft: `4px solid ${theme.palette.primary.dark}`
107 | },
108 | content: {
109 | flexGrow: 1,
110 | padding: theme.spacing(3)
111 | }
112 | })
113 | )
114 |
115 | export default () => {
116 | const [ navActive, setNavActive ] = useState(true)
117 |
118 | const classes = useStyles()
119 | const { location } = useRouter()
120 |
121 | return (
122 |
123 |
124 |
125 | setNavActive(!navActive)}
131 | >
132 |
133 |
134 |
135 | Twitch Play Sounds
136 |
137 |
138 |
139 |
153 |
154 |
155 | {routes.map(route => (
156 |
163 |
164 |
165 |
166 | {route.name}
167 |
168 | ))}
169 |
170 |
171 |
172 |
173 |
174 | {routes.map(route => (
175 | }
180 | />
181 | ))}
182 |
183 |
184 |
185 | )
186 | }
187 |
--------------------------------------------------------------------------------
/server/src/twitch.ts:
--------------------------------------------------------------------------------
1 | import * as jsonfile from 'jsonfile'
2 | import { Client, ChatUserstate } from 'tmi.js'
3 | import { TwitchConfig } from './types'
4 | import config from './config'
5 | import { dispatchSocket } from './socket'
6 | import { SOCKET, PERMISSIONS } from './enum'
7 | import { fetchSounds } from './datastore'
8 | import { findUser } from './users'
9 |
10 | const fileConfig = './db/db-twitch.json'
11 |
12 | class TwitchConnection {
13 | connected: boolean
14 | username: string | null
15 | oauth: string | null
16 | channels: string[] | null
17 |
18 | client: null | Client
19 |
20 | constructor () {
21 | this.connected = false
22 | this.username = null
23 | this.oauth = null
24 | this.channels = null
25 | this.client = null
26 | }
27 |
28 | readConfig = (): Promise => new Promise((resolve, reject) => {
29 | jsonfile.readFile(fileConfig, (err, cfg: TwitchConfig) => {
30 | if (err) reject(err)
31 | resolve(cfg)
32 | })
33 | })
34 |
35 | setConfig = (
36 | data: TwitchConfig
37 | ) => new Promise((resolve, reject) => {
38 | jsonfile.writeFile(fileConfig, data, (err) => {
39 | if (err) reject(err)
40 | resolve()
41 | })
42 | })
43 |
44 | onStartConnect = async () => {
45 | try {
46 | await this.readConfig()
47 | }
48 | catch (e) {
49 | console.log('Migrating twitch config DB')
50 | await this.setConfig({
51 | username: null,
52 | oauth: null,
53 | channels: null
54 | })
55 | }
56 | await this.loadConfig()
57 |
58 | if (this.oauth !== null) {
59 | try {
60 | await this.auth()
61 | }
62 | catch (e) {
63 | await this.resetConfig()
64 | }
65 | }
66 | }
67 |
68 | loadConfig = async () => {
69 | const config = await this.readConfig()
70 | this.username = config.username
71 | this.oauth = config.oauth
72 | this.channels = config.channels
73 | }
74 |
75 | updateConfig = async (config: TwitchConfig) => {
76 | await this.setConfig(config)
77 | this.username = config.username
78 | this.oauth = config.oauth
79 | this.channels = config.channels
80 | if (this.client && this.client.readyState() === 'OPEN') {
81 | await this.client.disconnect()
82 | await this.auth()
83 | }
84 | else {
85 | await this.auth()
86 | }
87 | }
88 |
89 | resetConfig = async () => {
90 | this.username = null
91 | this.oauth = null
92 | this.channels = null
93 | await this.setConfig({
94 | username: null,
95 | oauth: null,
96 | channels: null
97 | })
98 | }
99 |
100 | handleMessage = async (
101 | channel: string,
102 | user: ChatUserstate,
103 | message: string
104 | ) => {
105 | if (message.charAt(0) === '!') {
106 | const sounds = await fetchSounds()
107 | for (const sound of sounds) {
108 | if (message === sound.command) {
109 | const raw = user['badges-raw']
110 | const isUser = await findUser(user.username)
111 |
112 | if (isUser && isUser.flags.includes(PERMISSIONS.BANNED)) {
113 | return
114 | }
115 |
116 | switch (sound.access) {
117 | case 'ALL':
118 | dispatchSocket(SOCKET.PLAYER, sound)
119 | break
120 | case 'MOD':
121 | if (user.mod || (isUser && isUser.flags.includes(PERMISSIONS.ALL_ACCESS))) {
122 | dispatchSocket(SOCKET.PLAYER, sound)
123 | }
124 | break
125 | case 'SUB':
126 | if (user.subscriber
127 | || user.mod
128 | || (raw && raw.toLowerCase().includes('vip'))
129 | || (isUser && isUser.flags.includes(PERMISSIONS.ALL_ACCESS))
130 | ) {
131 | dispatchSocket(SOCKET.PLAYER, sound)
132 | }
133 | break
134 | case 'VIP':
135 | if (user.mod
136 | || (raw && raw.toLowerCase().includes('vip'))
137 | || (isUser && isUser.flags.includes(PERMISSIONS.ALL_ACCESS))
138 | ) {
139 | dispatchSocket(SOCKET.PLAYER, sound)
140 | }
141 | break
142 | }
143 | }
144 | }
145 | }
146 | }
147 |
148 | auth = async () => {
149 | try {
150 | if (this.username === null || this.oauth === null || this.channels === null) {
151 | throw new Error('No connection set!')
152 | }
153 |
154 | const options = {
155 | options: {
156 | debug: config.environment === 'DEVELOPMENT'
157 | },
158 | connection: {
159 | cluster: 'aws',
160 | reconnect: true
161 | },
162 | identity: {
163 | username: this.username,
164 | password: this.oauth
165 | },
166 | channels: this.channels
167 | }
168 |
169 | this.client = Client(options)
170 | this.client.connect()
171 |
172 | this.client.on('connected', () => {
173 | this.connected = true
174 | dispatchSocket(SOCKET.TWITCH, true)
175 | })
176 |
177 | this.client.on('disconnected', () => {
178 | this.connected = false
179 | dispatchSocket(SOCKET.TWITCH, false)
180 | })
181 |
182 | this.client.on('chat', (channel, user, message) => {
183 | this.handleMessage(channel, user, message)
184 | })
185 | }
186 | catch (e) {
187 | await this.resetConfig()
188 | return e
189 | }
190 | }
191 |
192 | getConfig = (): TwitchConfig => ({
193 | username: this.username,
194 | oauth: this.oauth,
195 | channels: this.channels
196 | })
197 |
198 | isAuth = (): boolean => this.connected
199 |
200 | disconnect = async () => {
201 | if (this.client) {
202 | await this.client.disconnect()
203 | this.connected = false
204 | dispatchSocket(SOCKET.TWITCH, false)
205 | await this.resetConfig()
206 | }
207 | }
208 | }
209 |
210 | const twitchConnection = new TwitchConnection()
211 |
212 | export default twitchConnection
213 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/dialogs/EditSoundDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { Dispatch, SetStateAction, Fragment } from 'react'
2 |
3 | import { EditSound } from '../../../types'
4 | import accessLevel from '../../../enums/accessLevels'
5 | import { customColors } from '../../../theme'
6 | import { IconHelper } from '../'
7 |
8 | import {
9 | Dialog,
10 | DialogActions,
11 | DialogTitle,
12 | DialogContent,
13 | Button,
14 | TextField,
15 | FormControl,
16 | InputLabel,
17 | OutlinedInput,
18 | Select,
19 | MenuItem,
20 | createStyles,
21 | makeStyles,
22 | Theme,
23 | Typography as T,
24 | Grid,
25 | Input,
26 | Slider,
27 | IconButton,
28 | Tooltip
29 | } from '@material-ui/core'
30 |
31 | import {
32 | FileUploadOutline,
33 | VolumeHigh,
34 | Play
35 | } from 'mdi-material-ui'
36 |
37 | const useStyles = makeStyles((theme: Theme) =>
38 | createStyles({
39 | text: {
40 | margin: theme.spacing(2, 0)
41 | },
42 | error: {
43 | color: customColors.danger.main
44 | },
45 | input: {
46 | display: 'none'
47 | },
48 | icon: {
49 | marginLeft: theme.spacing(1)
50 | },
51 | replay: {
52 | marginRight: theme.spacing(2)
53 | },
54 | menuitem: {
55 | verticalAlign: 'middle'
56 | },
57 | menuItemText: {
58 | display: 'inline-flex',
59 | verticalAlign: 'super',
60 | fontWeight: 400,
61 | marginLeft: theme.spacing(1)
62 | }
63 | })
64 | )
65 |
66 | interface Props {
67 | isOpen: boolean,
68 | onClose: () => void,
69 | onEdit: (sound: EditSound) => Promise,
70 | sound: EditSound | null,
71 | setSound: Dispatch>
72 | }
73 |
74 | export default ({
75 | isOpen,
76 | onClose,
77 | onEdit,
78 | sound,
79 | setSound
80 | }: Props) => {
81 | const classes = useStyles()
82 |
83 | const _handleClose = () => {
84 | onClose()
85 | }
86 |
87 | const _handleUpload = () => {
88 | if (sound !== null && sound.command !== '') {
89 | onEdit({
90 | id: sound.id,
91 | access: sound.access,
92 | command: sound.command,
93 | file: sound.file,
94 | level: Number(sound.level),
95 | path: sound.path
96 | })
97 | .then(() => {
98 | onClose()
99 | })
100 | .catch(() => {
101 | setSound(null)
102 | })
103 | }
104 | }
105 |
106 | const handleBlur = () => {
107 | if (sound && sound.level < 0) {
108 | setSound({ ...sound, level: 0 })
109 | }
110 | else if (sound && sound.level > 100) {
111 | setSound({ ...sound, level: 100 })
112 | }
113 | }
114 |
115 | const _playSound = () => {
116 | if ((sound && sound.file) || (sound && sound.path)) {
117 | const audio = new Audio(sound.file !== null
118 | ? window.URL.createObjectURL(sound.file)
119 | : '/' + sound.path)
120 |
121 | const volumeLevel = (Number(sound.level) / 100)
122 | audio.volume = 0.75 * volumeLevel
123 | audio.play()
124 | }
125 | }
126 |
127 | if (sound === null) {
128 | return
129 | }
130 |
131 | return (
132 |
263 | )
264 | }
265 |
--------------------------------------------------------------------------------
/client/src/components/dashboard/dialogs/NewSoundDialog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import { AccessLevel, NewSound } from '../../../types'
4 | import accessLevel from '../../../enums/accessLevels'
5 | import { customColors } from '../../../theme'
6 | import { IconHelper } from '../'
7 |
8 | import {
9 | Dialog,
10 | DialogActions,
11 | DialogTitle,
12 | DialogContent,
13 | Button,
14 | TextField,
15 | FormControl,
16 | InputLabel,
17 | OutlinedInput,
18 | Select,
19 | MenuItem,
20 | createStyles,
21 | makeStyles,
22 | Theme,
23 | Typography as T,
24 | Grid,
25 | Input,
26 | Slider,
27 | IconButton,
28 | Tooltip
29 | } from '@material-ui/core'
30 |
31 | import {
32 | FileUploadOutline,
33 | VolumeHigh,
34 | Play
35 | } from 'mdi-material-ui'
36 |
37 | const useStyles = makeStyles((theme: Theme) =>
38 | createStyles({
39 | text: {
40 | margin: theme.spacing(2, 0)
41 | },
42 | error: {
43 | color: customColors.danger.main
44 | },
45 | input: {
46 | display: 'none'
47 | },
48 | icon: {
49 | marginLeft: theme.spacing(1)
50 | },
51 | replay: {
52 | marginRight: theme.spacing(2)
53 | },
54 | menuitem: {
55 | verticalAlign: 'middle'
56 | },
57 | menuItemText: {
58 | display: 'inline-flex',
59 | verticalAlign: 'super',
60 | fontWeight: 400,
61 | marginLeft: theme.spacing(1)
62 | }
63 | })
64 | )
65 |
66 | interface Props {
67 | isOpen: boolean,
68 | onClose: () => void,
69 | onAdd: (sound: NewSound) => Promise
70 | }
71 |
72 | interface State {
73 | access: AccessLevel,
74 | command: string,
75 | file: File | null,
76 | level: number | number[],
77 | error: boolean
78 | }
79 |
80 | const getInitialState = (): State => ({
81 | access: 'ALL',
82 | command: '',
83 | file: null,
84 | level: 50,
85 | error: false
86 | })
87 |
88 | export default ({
89 | isOpen,
90 | onClose,
91 | onAdd
92 | }: Props) => {
93 | const classes = useStyles()
94 | const [state, setState] = useState(getInitialState)
95 |
96 | const _handleClose = () => {
97 | onClose()
98 | setState(getInitialState)
99 | }
100 |
101 | const _handleUpload = () => {
102 | if (state.file !== null && state.command !== '') {
103 | onAdd({
104 | access: state.access,
105 | command: state.command,
106 | file: state.file,
107 | level: Number(state.level)
108 | })
109 | .then(() => {
110 | onClose()
111 | setState(getInitialState)
112 | })
113 | .catch(() => {
114 | setState({
115 | ...state,
116 | error: true
117 | })
118 | })
119 | }
120 | }
121 |
122 | const handleBlur = () => {
123 | if (state.level < 0) {
124 | setState({ ...state, level: 0 })
125 | }
126 | else if (state.level > 100) {
127 | setState({ ...state, level: 100 })
128 | }
129 | }
130 |
131 | const _playSound = () => {
132 | if (state.file) {
133 | const audio = new Audio(window.URL.createObjectURL(state.file))
134 | const volumeLevel = (Number(state.level) / 100)
135 | audio.volume = 0.75 * volumeLevel
136 | audio.play()
137 | }
138 | }
139 |
140 | return (
141 |
277 | )
278 | }
279 |
--------------------------------------------------------------------------------