commit
ebf6a6423d
5
Makefile
5
Makefile
|
|
@ -7,12 +7,11 @@ ifdef GOTOOLCHAIN
|
||||||
else
|
else
|
||||||
GO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//')
|
GO_VERSION=$(shell go mod edit -json | jq -r .Toolchain | sed -e 's/go//')
|
||||||
endif
|
endif
|
||||||
DOCKER_BUILD_IMAGE=gotify/build
|
DOCKER_BUILD_IMAGE=docker.io/gotify/build
|
||||||
DOCKER_WORKDIR=/proj
|
DOCKER_WORKDIR=/proj
|
||||||
DOCKER_RUN=docker run --rm -e LD_FLAGS="$$LD_FLAGS" -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR}
|
DOCKER_RUN=docker run --rm -e LD_FLAGS="$$LD_FLAGS" -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR}
|
||||||
DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS"
|
DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS"
|
||||||
DOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build
|
DOCKER_TEST_LEVEL ?= 0 # Optionally run a test during docker build
|
||||||
NODE_OPTIONS=$(shell if node --help | grep -q -- "--openssl-legacy-provider"; then echo --openssl-legacy-provider; fi)
|
|
||||||
|
|
||||||
test: test-coverage test-js
|
test: test-coverage test-js
|
||||||
check: check-go check-swagger check-js
|
check: check-go check-swagger check-js
|
||||||
|
|
@ -116,7 +115,7 @@ _build_within_docker:
|
||||||
${DOCKER_GO_BUILD} -o ${OUTPUT}
|
${DOCKER_GO_BUILD} -o ${OUTPUT}
|
||||||
|
|
||||||
build-js:
|
build-js:
|
||||||
(cd ui && NODE_OPTIONS="${NODE_OPTIONS}" yarn build)
|
(cd ui && yarn build)
|
||||||
|
|
||||||
build-linux-amd64:
|
build-linux-amd64:
|
||||||
${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64
|
${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 make _build_within_docker OUTPUT=${BUILD_DIR}/gotify-linux-amd64
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
src/setupTests.ts
|
|
||||||
src/registerServiceWorker.ts
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
---
|
|
||||||
extends:
|
|
||||||
- eslint:recommended
|
|
||||||
- plugin:@typescript-eslint/eslint-recommended
|
|
||||||
- plugin:@typescript-eslint/recommended
|
|
||||||
- plugin:@typescript-eslint/recommended-requiring-type-checking
|
|
||||||
- plugin:react/recommended
|
|
||||||
- plugin:import/errors
|
|
||||||
- plugin:import/typescript
|
|
||||||
- plugin:jest/recommended
|
|
||||||
- prettier
|
|
||||||
env:
|
|
||||||
browser: true
|
|
||||||
es6: true
|
|
||||||
node: true
|
|
||||||
parser: "@typescript-eslint/parser"
|
|
||||||
parserOptions:
|
|
||||||
project: tsconfig.json
|
|
||||||
sourceType: module
|
|
||||||
plugins:
|
|
||||||
- "@typescript-eslint"
|
|
||||||
- react
|
|
||||||
- import
|
|
||||||
- unicorn
|
|
||||||
settings:
|
|
||||||
react:
|
|
||||||
version: detect
|
|
||||||
rules:
|
|
||||||
consistent-return: error
|
|
||||||
default-case: error
|
|
||||||
default-param-last: error
|
|
||||||
no-loop-func: off
|
|
||||||
arrow-body-style: [error, as-needed]
|
|
||||||
|
|
||||||
import/no-useless-path-segments: error
|
|
||||||
import/group-exports: off
|
|
||||||
import/extensions: [error, never]
|
|
||||||
import/no-duplicates: error
|
|
||||||
import/first: error
|
|
||||||
import/no-unused-modules: error
|
|
||||||
|
|
||||||
unicorn/no-abusive-eslint-disable: off
|
|
||||||
unicorn/no-array-instanceof: error
|
|
||||||
unicorn/no-unreadable-array-destructuring: error
|
|
||||||
unicorn/no-zero-fractions: error
|
|
||||||
|
|
||||||
react/jsx-key: error
|
|
||||||
react/jsx-pascal-case: error
|
|
||||||
react/destructuring-assignment: off
|
|
||||||
react/function-component-definition: off
|
|
||||||
react/no-array-index-key: error
|
|
||||||
react/no-deprecated: off
|
|
||||||
react/no-string-refs: error
|
|
||||||
react/no-this-in-sfc: error
|
|
||||||
react/no-typos: error
|
|
||||||
react/no-unknown-property: error
|
|
||||||
react/prefer-stateless-function: off
|
|
||||||
react/prop-types: off
|
|
||||||
|
|
||||||
jest/expect-expect: off
|
|
||||||
jest/no-jasmine-globals: off
|
|
||||||
"@typescript-eslint/require-await": off
|
|
||||||
"@typescript-eslint/restrict-template-expressions": off
|
|
||||||
|
|
||||||
"@typescript-eslint/array-type": [error, {default: array-simple}]
|
|
||||||
"@typescript-eslint/await-thenable": error
|
|
||||||
"@typescript-eslint/no-unused-vars": error
|
|
||||||
"@typescript-eslint/no-use-before-define": off
|
|
||||||
"@typescript-eslint/no-unsafe-call": off
|
|
||||||
"@typescript-eslint/consistent-type-assertions": [error, {assertionStyle: as}]
|
|
||||||
|
|
||||||
"@typescript-eslint/no-extra-non-null-assertion": error
|
|
||||||
"@typescript-eslint/no-inferrable-types": error
|
|
||||||
"@typescript-eslint/no-this-alias": error
|
|
||||||
"@typescript-eslint/no-throw-literal": error
|
|
||||||
"@typescript-eslint/no-non-null-assertion": off
|
|
||||||
"@typescript-eslint/prefer-nullish-coalescing": error
|
|
||||||
"@typescript-eslint/prefer-optional-chain": error
|
|
||||||
"@typescript-eslint/prefer-readonly": off
|
|
||||||
"@typescript-eslint/unbound-method": error
|
|
||||||
"@typescript-eslint/no-empty-function": off
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": off
|
|
||||||
"@typescript-eslint/ban-ts-comment": off
|
|
||||||
"@typescript-eslint/no-floating-promises": off
|
|
||||||
"@typescript-eslint/no-unsafe-member-access": off
|
|
||||||
"@typescript-eslint/no-unsafe-return": off
|
|
||||||
"@typescript-eslint/no-unsafe-assignment": off
|
|
||||||
"@typescript-eslint/restrict-plus-operands": off
|
|
||||||
"@typescript-eslint/no-misused-promises": off
|
|
||||||
|
|
||||||
"@typescript-eslint/no-explicit-any": error
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended);
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
<meta name="theme-color" content="#3f51b5">
|
<meta name="theme-color" content="#3f51b5">
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<title>Gotify</title>
|
<title>Gotify</title>
|
||||||
|
|
||||||
<link rel="apple-touch-icon-precomposed" sizes="57x57" href="static/apple-touch-icon-57x57.png" />
|
<link rel="apple-touch-icon-precomposed" sizes="57x57" href="static/apple-touch-icon-57x57.png" />
|
||||||
|
|
@ -35,8 +35,7 @@
|
||||||
Gotify requires JavaScript.
|
Gotify requires JavaScript.
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<% if (process.env.NODE_ENV === 'production') { %>
|
<script>window.config = %CONFIG%;</script>
|
||||||
<script>window.config = %CONFIG%;</script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
<% } %>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
101
ui/package.json
101
ui/package.json
|
|
@ -5,72 +5,59 @@
|
||||||
"homepage": ".",
|
"homepage": ".",
|
||||||
"proxy": "http://localhost:80",
|
"proxy": "http://localhost:80",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-ui/core": "^4.11.4",
|
"@emotion/react": "^11.14.0",
|
||||||
"@material-ui/icons": "^4.9.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"axios": "^0.21.1",
|
"@mui/icons-material": "^7.2.0",
|
||||||
"codemirror": "^5.61.1",
|
"@mui/material": "^7.2.0",
|
||||||
"detect-browser": "^5.2.0",
|
"@uiw/codemirror-theme-material": "^4.24.2",
|
||||||
"js-base64": "^3.6.1",
|
"@uiw/react-codemirror": "^4.24.2",
|
||||||
"mobx": "^5.15.6",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"mobx-react": "^6.3.0",
|
"axios": "^1.11.0",
|
||||||
"mobx-utils": "^5.6.1",
|
"detect-browser": "^5.3.0",
|
||||||
|
"mobx": "^6.13.7",
|
||||||
|
"mobx-react": "^9.2.0",
|
||||||
|
"mobx-utils": "^6.1.1",
|
||||||
"notifyjs": "^3.0.0",
|
"notifyjs": "^3.0.0",
|
||||||
"prop-types": "^15.6.2",
|
"notistack": "^3.0.2",
|
||||||
"react": "^16.4.2",
|
"react": "^19.1.1",
|
||||||
"react-codemirror2": "^7.2.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-dom": "^16.4.2",
|
"react-markdown": "^10.1.0",
|
||||||
"react-infinite": "^0.13.0",
|
"react-router": "^7.7.1",
|
||||||
"react-markdown": "^6.0.2",
|
"react-router-dom": "^7.7.1",
|
||||||
"react-router": "^5.2.0",
|
"react-timeago": "^8.3.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-virtuoso": "^4.13.0",
|
||||||
"react-timeago": "^6.2.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-gfm": "^1.0.0",
|
"remove-markdown": "^0.6.2",
|
||||||
"remove-markdown": "^0.3.0",
|
"tss-react": "^4.9.19",
|
||||||
"typeface-roboto": "1.1.13"
|
"typeface-roboto": "1.1.13",
|
||||||
|
"vite": "^7.0.6",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "vite",
|
||||||
"build": "react-scripts build",
|
"prebuild": "tsc",
|
||||||
"test": "react-scripts test --env=node",
|
"build": "vite build",
|
||||||
"eject": "react-scripts eject",
|
"test": "vitest --disable-console-intercept --no-file-parallelism",
|
||||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||||
"format": "prettier \"src/**/*.{ts,tsx}\" --write",
|
"format": "prettier \"src/**/*.{ts,tsx}\" --write",
|
||||||
"testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different"
|
"testformat": "prettier \"src/**/*.{ts,tsx}\" --list-different"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/codemirror": "5.60.0",
|
"@eslint/js": "^9.32.0",
|
||||||
"@types/detect-browser": "^4.0.0",
|
"@types/notifyjs": "^3.0.5",
|
||||||
"@types/get-port": "^4.0.0",
|
"@types/react": "^19.1.9",
|
||||||
"@types/jest": "^26.0.23",
|
"@types/react-dom": "^19.1.7",
|
||||||
"@types/js-base64": "^3.3.1",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@types/node": "^15.12.2",
|
"@types/remove-markdown": "^0.3.4",
|
||||||
"@types/notifyjs": "^3.0.2",
|
"eslint": "^9.32.0",
|
||||||
"@types/puppeteer": "^5.4.6",
|
"get-port": "^7.1.0",
|
||||||
"@types/react": "^16.9.49",
|
"prettier": "^3.6.2",
|
||||||
"@types/react-dom": "^16.9.8",
|
"puppeteer": "^24.15.0",
|
||||||
"@types/react-infinite": "0.0.35",
|
"rimraf": "^6.0.1",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
|
||||||
"@types/remove-markdown": "^0.3.0",
|
|
||||||
"@types/rimraf": "^3.0.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^4.1.0",
|
|
||||||
"@typescript-eslint/parser": "^4.1.0",
|
|
||||||
"eslint-config-prettier": "^6.11.0",
|
|
||||||
"eslint-plugin-import": "^2.22.0",
|
|
||||||
"eslint-plugin-jest": "^24.0.0",
|
|
||||||
"eslint-plugin-prefer-arrow": "^1.2.2",
|
|
||||||
"eslint-plugin-react": "^7.20.6",
|
|
||||||
"eslint-plugin-unicorn": "^21.0.0",
|
|
||||||
"get-port": "^5.1.1",
|
|
||||||
"prettier": "^2.3.1",
|
|
||||||
"puppeteer": "^17.1.3",
|
|
||||||
"react-scripts": "^4.0.3",
|
|
||||||
"rimraf": "^3.0.2",
|
|
||||||
"tree-kill": "^1.2.0",
|
"tree-kill": "^1.2.0",
|
||||||
"typescript": "4.0.2",
|
"typescript": "^5.9.2",
|
||||||
"wait-on": "^5.3.0"
|
"typescript-eslint": "^8.38.0",
|
||||||
},
|
"wait-on": "^8.0.4"
|
||||||
"eslintConfig": {
|
|
||||||
"extends": "react-app"
|
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ func Register(r *gin.Engine, version model.VersionInfo, register bool) {
|
||||||
ui.GET("/", serveFile("index.html", "text/html", replaceConfig))
|
ui.GET("/", serveFile("index.html", "text/html", replaceConfig))
|
||||||
ui.GET("/index.html", serveFile("index.html", "text/html", replaceConfig))
|
ui.GET("/index.html", serveFile("index.html", "text/html", replaceConfig))
|
||||||
ui.GET("/manifest.json", serveFile("manifest.json", "application/json", noop))
|
ui.GET("/manifest.json", serveFile("manifest.json", "application/json", noop))
|
||||||
ui.GET("/asset-manifest.json", serveFile("asset-manifest.json", "application/json", noop))
|
|
||||||
|
|
||||||
subBox, err := fs.Sub(box, "build")
|
subBox, err := fs.Sub(box, "build")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import axios, {AxiosError, AxiosResponse} from 'axios';
|
import axios, {AxiosError, AxiosResponse} from 'axios';
|
||||||
import * as config from './config';
|
import * as config from './config';
|
||||||
import {Base64} from 'js-base64';
|
|
||||||
import {detect} from 'detect-browser';
|
import {detect} from 'detect-browser';
|
||||||
import {SnackReporter} from './snack/SnackManager';
|
import {SnackReporter} from './snack/SnackManager';
|
||||||
import {observable} from 'mobx';
|
import {observable, makeObservable} from 'mobx';
|
||||||
import {IClient, IUser} from './types';
|
import {IClient, IUser} from './types';
|
||||||
|
|
||||||
const tokenKey = 'gotify-login-key';
|
const tokenKey = 'gotify-login-key';
|
||||||
|
|
@ -12,16 +11,21 @@ export class CurrentUser {
|
||||||
private tokenCache: string | null = null;
|
private tokenCache: string | null = null;
|
||||||
private reconnectTimeoutId: number | null = null;
|
private reconnectTimeoutId: number | null = null;
|
||||||
private reconnectTime = 7500;
|
private reconnectTime = 7500;
|
||||||
@observable
|
|
||||||
public loggedIn = false;
|
public loggedIn = false;
|
||||||
@observable
|
public refreshKey = 0;
|
||||||
public authenticating = true;
|
public authenticating = true;
|
||||||
@observable
|
|
||||||
public user: IUser = {name: 'unknown', admin: false, id: -1};
|
public user: IUser = {name: 'unknown', admin: false, id: -1};
|
||||||
@observable
|
|
||||||
public connectionErrorMessage: string | null = null;
|
public connectionErrorMessage: string | null = null;
|
||||||
|
|
||||||
public constructor(private readonly snack: SnackReporter) {}
|
public constructor(private readonly snack: SnackReporter) {
|
||||||
|
makeObservable(this, {
|
||||||
|
loggedIn: observable,
|
||||||
|
authenticating: observable,
|
||||||
|
user: observable,
|
||||||
|
connectionErrorMessage: observable,
|
||||||
|
refreshKey: observable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public token = (): string => {
|
public token = (): string => {
|
||||||
if (this.tokenCache !== null) {
|
if (this.tokenCache !== null) {
|
||||||
|
|
@ -51,12 +55,13 @@ export class CurrentUser {
|
||||||
this.login(name, pass);
|
this.login(name, pass);
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.catch((error: AxiosError) => {
|
.catch((error: AxiosError<{error?: string; errorDescription?: string}>) => {
|
||||||
if (!error || !error.response) {
|
if (!error || !error.response) {
|
||||||
this.snack('No network connection or server unavailable.');
|
this.snack('No network connection or server unavailable.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const {data} = error.response;
|
const {data} = error.response;
|
||||||
|
|
||||||
this.snack(
|
this.snack(
|
||||||
`Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}`
|
`Register failed: ${data?.error ?? 'unknown'}: ${data?.errorDescription ?? ''}`
|
||||||
);
|
);
|
||||||
|
|
@ -74,8 +79,7 @@ export class CurrentUser {
|
||||||
url: config.get('url') + 'client',
|
url: config.get('url') + 'client',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {name},
|
data: {name},
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
headers: {Authorization: 'Basic ' + btoa(username + ':' + password)},
|
||||||
headers: {Authorization: 'Basic ' + Base64.encode(username + ':' + password)},
|
|
||||||
})
|
})
|
||||||
.then((resp: AxiosResponse<IClient>) => {
|
.then((resp: AxiosResponse<IClient>) => {
|
||||||
this.snack(`A client named '${name}' was created for your session.`);
|
this.snack(`A client named '${name}' was created for your session.`);
|
||||||
|
|
@ -98,10 +102,8 @@ export class CurrentUser {
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return axios
|
||||||
axios
|
|
||||||
.create()
|
.create()
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
.get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}})
|
.get(config.get('url') + 'current/user', {headers: {'X-Gotify-Key': this.token()}})
|
||||||
.then((passThrough) => {
|
.then((passThrough) => {
|
||||||
this.user = passThrough.data;
|
this.user = passThrough.data;
|
||||||
|
|
@ -131,8 +133,7 @@ export class CurrentUser {
|
||||||
this.logout();
|
this.logout();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
})
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public logout = async () => {
|
public logout = async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,35 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import {NumberField} from '../common/NumberField';
|
import {NumberField} from '../common/NumberField';
|
||||||
import React, {Component} from 'react';
|
import React, {useState} from 'react';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
fClose: VoidFunction;
|
fClose: VoidFunction;
|
||||||
fOnSubmit: (name: string, description: string, defaultPriority: number) => void;
|
fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
export const AddApplicationDialog = ({fClose, fOnSubmit}: IProps) => {
|
||||||
name: string;
|
const [name, setName] = useState('');
|
||||||
description: string;
|
const [description, setDescription] = useState('');
|
||||||
defaultPriority: number;
|
const [defaultPriority, setDefaultPriority] = useState(0);
|
||||||
}
|
|
||||||
|
|
||||||
export default class AddDialog extends Component<IProps, IState> {
|
const submitEnabled = name.length !== 0;
|
||||||
public state = {name: '', description: '', defaultPriority: 0};
|
const submitAndClose = async () => {
|
||||||
|
await fOnSubmit(name, description, defaultPriority);
|
||||||
public render() {
|
|
||||||
const {fClose, fOnSubmit} = this.props;
|
|
||||||
const {name, description, defaultPriority} = this.state;
|
|
||||||
const submitEnabled = this.state.name.length !== 0;
|
|
||||||
const submitAndClose = () => {
|
|
||||||
fOnSubmit(name, description, defaultPriority);
|
|
||||||
fClose();
|
fClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
|
||||||
open={true}
|
|
||||||
onClose={fClose}
|
|
||||||
aria-labelledby="form-dialog-title"
|
|
||||||
id="app-dialog">
|
|
||||||
<DialogTitle id="form-dialog-title">Create an application</DialogTitle>
|
<DialogTitle id="form-dialog-title">Create an application</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>An application is allowed to send messages.</DialogContentText>
|
||||||
An application is allowed to send messages.
|
|
||||||
</DialogContentText>
|
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
|
@ -49,7 +37,7 @@ export default class AddDialog extends Component<IProps, IState> {
|
||||||
label="Name *"
|
label="Name *"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={this.handleChange.bind(this, 'name')}
|
onChange={(e) => setName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -57,7 +45,7 @@ export default class AddDialog extends Component<IProps, IState> {
|
||||||
className="description"
|
className="description"
|
||||||
label="Short Description"
|
label="Short Description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={this.handleChange.bind(this, 'description')}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
|
|
@ -66,7 +54,7 @@ export default class AddDialog extends Component<IProps, IState> {
|
||||||
className="priority"
|
className="priority"
|
||||||
label="Default Priority"
|
label="Default Priority"
|
||||||
value={defaultPriority}
|
value={defaultPriority}
|
||||||
onChange={(value) => this.setState({defaultPriority: value})}
|
onChange={(value) => setDefaultPriority(value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -87,11 +75,4 @@ export default class AddDialog extends Component<IProps, IState> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const state = this.state;
|
|
||||||
state[propertyName] = event.target.value;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {BaseStore} from '../common/BaseStore';
|
import {BaseStore} from '../common/BaseStore';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import {action} from 'mobx';
|
import {action, makeObservable} from 'mobx';
|
||||||
import {SnackReporter} from '../snack/SnackManager';
|
import {SnackReporter} from '../snack/SnackManager';
|
||||||
import {IApplication} from '../types';
|
import {IApplication} from '../types';
|
||||||
|
|
||||||
|
|
@ -10,6 +10,12 @@ export class AppStore extends BaseStore<IApplication> {
|
||||||
|
|
||||||
public constructor(private readonly snack: SnackReporter) {
|
public constructor(private readonly snack: SnackReporter) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
uploadImage: action,
|
||||||
|
update: action,
|
||||||
|
create: action,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected requestItems = (): Promise<IApplication[]> =>
|
protected requestItems = (): Promise<IApplication[]> =>
|
||||||
|
|
@ -23,7 +29,6 @@ export class AppStore extends BaseStore<IApplication> {
|
||||||
return this.snack('Application deleted');
|
return this.snack('Application deleted');
|
||||||
});
|
});
|
||||||
|
|
||||||
@action
|
|
||||||
public uploadImage = async (id: number, file: Blob): Promise<void> => {
|
public uploadImage = async (id: number, file: Blob): Promise<void> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
@ -34,7 +39,6 @@ export class AppStore extends BaseStore<IApplication> {
|
||||||
this.snack('Application image updated');
|
this.snack('Application image updated');
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public update = async (
|
public update = async (
|
||||||
id: number,
|
id: number,
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -50,7 +54,6 @@ export class AppStore extends BaseStore<IApplication> {
|
||||||
this.snack('Application updated');
|
this.snack('Application updated');
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public create = async (
|
public create = async (
|
||||||
name: string,
|
name: string,
|
||||||
description: string,
|
description: string,
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,59 @@
|
||||||
import Grid from '@material-ui/core/Grid';
|
import React, {ChangeEvent, useEffect, useRef, useState} from 'react';
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
import Grid from '@mui/material/Grid';
|
||||||
import Paper from '@material-ui/core/Paper';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Table from '@material-ui/core/Table';
|
import Paper from '@mui/material/Paper';
|
||||||
import TableBody from '@material-ui/core/TableBody';
|
import Table from '@mui/material/Table';
|
||||||
import TableCell from '@material-ui/core/TableCell';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableHead from '@material-ui/core/TableHead';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableRow from '@material-ui/core/TableRow';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import Delete from '@material-ui/icons/Delete';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Edit from '@material-ui/icons/Edit';
|
import Delete from '@mui/icons-material/Delete';
|
||||||
import CloudUpload from '@material-ui/icons/CloudUpload';
|
import Edit from '@mui/icons-material/Edit';
|
||||||
import React, {ChangeEvent, Component, SFC} from 'react';
|
import CloudUpload from '@mui/icons-material/CloudUpload';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
import ConfirmDialog from '../common/ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import Button from '@material-ui/core/Button';
|
|
||||||
import CopyableSecret from '../common/CopyableSecret';
|
import CopyableSecret from '../common/CopyableSecret';
|
||||||
import AddApplicationDialog from './AddApplicationDialog';
|
import {AddApplicationDialog} from './AddApplicationDialog';
|
||||||
import {observer} from 'mobx-react';
|
|
||||||
import {observable} from 'mobx';
|
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import UpdateDialog from './UpdateApplicationDialog';
|
import {UpdateApplicationDialog} from './UpdateApplicationDialog';
|
||||||
import {IApplication} from '../types';
|
import {IApplication} from '../types';
|
||||||
import {LastUsedCell} from '../common/LastUsedCell';
|
import {LastUsedCell} from '../common/LastUsedCell';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
import {observer} from 'mobx-react-lite';
|
||||||
|
|
||||||
@observer
|
const Applications = observer(() => {
|
||||||
class Applications extends Component<Stores<'appStore'>> {
|
const {appStore} = useStores();
|
||||||
@observable
|
|
||||||
private deleteId: number | false = false;
|
|
||||||
@observable
|
|
||||||
private updateId: number | false = false;
|
|
||||||
@observable
|
|
||||||
private createDialog = false;
|
|
||||||
|
|
||||||
private uploadId = -1;
|
|
||||||
private upload: HTMLInputElement | null = null;
|
|
||||||
|
|
||||||
public componentDidMount = () => this.props.appStore.refresh();
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {
|
|
||||||
createDialog,
|
|
||||||
deleteId,
|
|
||||||
updateId,
|
|
||||||
props: {appStore},
|
|
||||||
} = this;
|
|
||||||
const apps = appStore.getItems();
|
const apps = appStore.getItems();
|
||||||
|
const [toDeleteApp, setToDeleteApp] = useState<IApplication>();
|
||||||
|
const [toUpdateApp, setToUpdateApp] = useState<IApplication>();
|
||||||
|
const [createDialog, setCreateDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const uploadId = useRef(-1);
|
||||||
|
|
||||||
|
useEffect(() => void appStore.refresh(), []);
|
||||||
|
|
||||||
|
const handleImageUploadClick = (id: number) => {
|
||||||
|
uploadId.current = id;
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUploadImage = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) {
|
||||||
|
appStore.uploadImage(uploadId.current, file);
|
||||||
|
} else {
|
||||||
|
alert('Uploaded file must be of type png, jpeg or gif.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultPage
|
<DefaultPage
|
||||||
title="Applications"
|
title="Applications"
|
||||||
|
|
@ -53,12 +62,12 @@ class Applications extends Component<Stores<'appStore'>> {
|
||||||
id="create-app"
|
id="create-app"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => (this.createDialog = true)}>
|
onClick={() => setCreateDialog(true)}>
|
||||||
Create Application
|
Create Application
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
maxWidth={1000}>
|
maxWidth={1000}>
|
||||||
<Grid item xs={12}>
|
<Grid size={12}>
|
||||||
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
||||||
<Table id="app-table">
|
<Table id="app-table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|
@ -83,70 +92,50 @@ class Applications extends Component<Stores<'appStore'>> {
|
||||||
name={app.name}
|
name={app.name}
|
||||||
value={app.token}
|
value={app.token}
|
||||||
lastUsed={app.lastUsed}
|
lastUsed={app.lastUsed}
|
||||||
fUpload={() => this.uploadImage(app.id)}
|
fUpload={() => handleImageUploadClick(app.id)}
|
||||||
fDelete={() => (this.deleteId = app.id)}
|
fDelete={() => setToDeleteApp(app)}
|
||||||
fEdit={() => (this.updateId = app.id)}
|
fEdit={() => setToUpdateApp(app)}
|
||||||
noDelete={app.internal}
|
noDelete={app.internal}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
<input
|
<input
|
||||||
ref={(upload) => (this.upload = upload)}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
style={{display: 'none'}}
|
style={{display: 'none'}}
|
||||||
onChange={this.onUploadImage}
|
onChange={onUploadImage}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
{createDialog && (
|
{createDialog && (
|
||||||
<AddApplicationDialog
|
<AddApplicationDialog
|
||||||
fClose={() => (this.createDialog = false)}
|
fClose={() => setCreateDialog(false)}
|
||||||
fOnSubmit={appStore.create}
|
fOnSubmit={appStore.create}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{updateId !== false && (
|
{toUpdateApp != null && (
|
||||||
<UpdateDialog
|
<UpdateApplicationDialog
|
||||||
fClose={() => (this.updateId = false)}
|
fClose={() => setToUpdateApp(undefined)}
|
||||||
fOnSubmit={(name, description, defaultPriority) =>
|
fOnSubmit={(name, description, defaultPriority) =>
|
||||||
appStore.update(updateId, name, description, defaultPriority)
|
appStore.update(toUpdateApp.id, name, description, defaultPriority)
|
||||||
}
|
}
|
||||||
initialDescription={appStore.getByID(updateId).description}
|
initialDescription={toUpdateApp?.description}
|
||||||
initialName={appStore.getByID(updateId).name}
|
initialName={toUpdateApp?.name}
|
||||||
initialDefaultPriority={appStore.getByID(updateId).defaultPriority}
|
initialDefaultPriority={toUpdateApp?.defaultPriority}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{deleteId !== false && (
|
{toDeleteApp != null && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Confirm Delete"
|
title="Confirm Delete"
|
||||||
text={'Delete ' + appStore.getByID(deleteId).name + '?'}
|
text={'Delete ' + toDeleteApp.name + '?'}
|
||||||
fClose={() => (this.deleteId = false)}
|
fClose={() => setToDeleteApp(undefined)}
|
||||||
fOnSubmit={() => appStore.remove(deleteId)}
|
fOnSubmit={() => appStore.remove(toDeleteApp.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
private uploadImage = (id: number) => {
|
|
||||||
this.uploadId = id;
|
|
||||||
if (this.upload) {
|
|
||||||
this.upload.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private onUploadImage = (e: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (['image/png', 'image/jpeg', 'image/gif'].indexOf(file.type) !== -1) {
|
|
||||||
this.props.appStore.uploadImage(this.uploadId, file);
|
|
||||||
} else {
|
|
||||||
alert('Uploaded file must be of type png, jpeg or gif.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IRowProps {
|
interface IRowProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -161,8 +150,7 @@ interface IRowProps {
|
||||||
fEdit: VoidFunction;
|
fEdit: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Row: SFC<IRowProps> = observer(
|
const Row = ({
|
||||||
({
|
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
noDelete,
|
noDelete,
|
||||||
|
|
@ -173,9 +161,10 @@ const Row: SFC<IRowProps> = observer(
|
||||||
fUpload,
|
fUpload,
|
||||||
image,
|
image,
|
||||||
fEdit,
|
fEdit,
|
||||||
}) => (
|
}: IRowProps) => {
|
||||||
|
return (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell padding="default">
|
<TableCell padding="normal">
|
||||||
<div style={{display: 'flex'}}>
|
<div style={{display: 'flex'}}>
|
||||||
<img src={config.get('url') + image} alt="app logo" width="40" height="40" />
|
<img src={config.get('url') + image} alt="app logo" width="40" height="40" />
|
||||||
<IconButton onClick={fUpload} style={{height: 40}}>
|
<IconButton onClick={fUpload} style={{height: 40}}>
|
||||||
|
|
@ -203,7 +192,7 @@ const Row: SFC<IRowProps> = observer(
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default inject('appStore')(Applications);
|
export default Applications;
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,44 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import {NumberField} from '../common/NumberField';
|
import {NumberField} from '../common/NumberField';
|
||||||
import React, {Component} from 'react';
|
import React, {useState} from 'react';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
fClose: VoidFunction;
|
fClose: VoidFunction;
|
||||||
fOnSubmit: (name: string, description: string, defaultPriority: number) => void;
|
fOnSubmit: (name: string, description: string, defaultPriority: number) => Promise<void>;
|
||||||
initialName: string;
|
initialName: string;
|
||||||
initialDescription: string;
|
initialDescription: string;
|
||||||
initialDefaultPriority: number;
|
initialDefaultPriority: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
export const UpdateApplicationDialog = ({
|
||||||
name: string;
|
initialName,
|
||||||
description: string;
|
initialDescription,
|
||||||
defaultPriority: number;
|
initialDefaultPriority,
|
||||||
}
|
fClose,
|
||||||
|
fOnSubmit,
|
||||||
|
}: IProps) => {
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [description, setDescription] = useState(initialDescription);
|
||||||
|
const [defaultPriority, setDefaultPriority] = useState(initialDefaultPriority);
|
||||||
|
|
||||||
export default class UpdateDialog extends Component<IProps, IState> {
|
const submitEnabled = name.length !== 0;
|
||||||
public state = {name: '', description: '', defaultPriority: 0};
|
const submitAndClose = async () => {
|
||||||
|
await fOnSubmit(name, description, defaultPriority);
|
||||||
constructor(props: IProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
name: props.initialName,
|
|
||||||
description: props.initialDescription,
|
|
||||||
defaultPriority: props.initialDefaultPriority,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {fClose, fOnSubmit} = this.props;
|
|
||||||
const {name, description, defaultPriority} = this.state;
|
|
||||||
const submitEnabled = this.state.name.length !== 0;
|
|
||||||
const submitAndClose = () => {
|
|
||||||
fOnSubmit(name, description, defaultPriority);
|
|
||||||
fClose();
|
fClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="app-dialog">
|
||||||
open={true}
|
|
||||||
onClose={fClose}
|
|
||||||
aria-labelledby="form-dialog-title"
|
|
||||||
id="app-dialog">
|
|
||||||
<DialogTitle id="form-dialog-title">Update an application</DialogTitle>
|
<DialogTitle id="form-dialog-title">Update an application</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>An application is allowed to send messages.</DialogContentText>
|
||||||
An application is allowed to send messages.
|
|
||||||
</DialogContentText>
|
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
|
@ -61,7 +46,7 @@ export default class UpdateDialog extends Component<IProps, IState> {
|
||||||
label="Name *"
|
label="Name *"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={this.handleChange.bind(this, 'name')}
|
onChange={(e) => setName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -69,7 +54,7 @@ export default class UpdateDialog extends Component<IProps, IState> {
|
||||||
className="description"
|
className="description"
|
||||||
label="Short Description"
|
label="Short Description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={this.handleChange.bind(this, 'description')}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
/>
|
/>
|
||||||
|
|
@ -78,7 +63,7 @@ export default class UpdateDialog extends Component<IProps, IState> {
|
||||||
className="priority"
|
className="priority"
|
||||||
label="Default Priority"
|
label="Default Priority"
|
||||||
value={defaultPriority}
|
value={defaultPriority}
|
||||||
onChange={(value) => this.setState({defaultPriority: value})}
|
onChange={(e) => setDefaultPriority(e)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -99,11 +84,4 @@ export default class UpdateDialog extends Component<IProps, IState> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const state = this.state;
|
|
||||||
state[propertyName] = event.target.value;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,28 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import React, {useState} from 'react';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Button from '@mui/material/Button';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import TextField from '@mui/material/TextField';
|
||||||
import React, {Component} from 'react';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
fClose: VoidFunction;
|
fClose: VoidFunction;
|
||||||
fOnSubmit: (name: string) => void;
|
fOnSubmit: (name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class AddDialog extends Component<IProps, {name: string}> {
|
const AddClientDialog = ({fClose, fOnSubmit}: IProps) => {
|
||||||
public state = {name: ''};
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
public render() {
|
const submitEnabled = name.length !== 0;
|
||||||
const {fClose, fOnSubmit} = this.props;
|
const submitAndClose = async () => {
|
||||||
const {name} = this.state;
|
await fOnSubmit(name);
|
||||||
const submitEnabled = this.state.name.length !== 0;
|
|
||||||
const submitAndClose = () => {
|
|
||||||
fOnSubmit(name);
|
|
||||||
fClose();
|
fClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="client-dialog">
|
||||||
open={true}
|
|
||||||
onClose={fClose}
|
|
||||||
aria-labelledby="form-dialog-title"
|
|
||||||
id="client-dialog">
|
|
||||||
<DialogTitle id="form-dialog-title">Create a client</DialogTitle>
|
<DialogTitle id="form-dialog-title">Create a client</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -38,15 +32,13 @@ export default class AddDialog extends Component<IProps, {name: string}> {
|
||||||
label="Name *"
|
label="Name *"
|
||||||
type="email"
|
type="email"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={this.handleChange.bind(this, 'name')}
|
onChange={(e) => setName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={fClose}>Cancel</Button>
|
<Button onClick={fClose}>Cancel</Button>
|
||||||
<Tooltip
|
<Tooltip placement={'bottom-start'} title={submitEnabled ? '' : 'name is required'}>
|
||||||
placement={'bottom-start'}
|
|
||||||
title={submitEnabled ? '' : 'name is required'}>
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
className="create"
|
className="create"
|
||||||
|
|
@ -61,11 +53,6 @@ export default class AddDialog extends Component<IProps, {name: string}> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
|
export default AddClientDialog;
|
||||||
const state = this.state;
|
|
||||||
state[propertyName] = event.target.value;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import {BaseStore} from '../common/BaseStore';
|
import {BaseStore} from '../common/BaseStore';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import {action} from 'mobx';
|
import {action, makeObservable} from 'mobx';
|
||||||
import {SnackReporter} from '../snack/SnackManager';
|
import {SnackReporter} from '../snack/SnackManager';
|
||||||
import {IClient} from '../types';
|
import {IClient} from '../types';
|
||||||
|
|
||||||
export class ClientStore extends BaseStore<IClient> {
|
export class ClientStore extends BaseStore<IClient> {
|
||||||
public constructor(private readonly snack: SnackReporter) {
|
public constructor(private readonly snack: SnackReporter) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
update: action,
|
||||||
|
createNoNotifcation: action,
|
||||||
|
create: action,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected requestItems = (): Promise<IClient[]> =>
|
protected requestItems = (): Promise<IClient[]> =>
|
||||||
|
|
@ -19,21 +25,18 @@ export class ClientStore extends BaseStore<IClient> {
|
||||||
.then(() => this.snack('Client deleted'));
|
.then(() => this.snack('Client deleted'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
public update = async (id: number, name: string): Promise<void> => {
|
public update = async (id: number, name: string): Promise<void> => {
|
||||||
await axios.put(`${config.get('url')}client/${id}`, {name});
|
await axios.put(`${config.get('url')}client/${id}`, {name});
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
this.snack('Client updated');
|
this.snack('Client updated');
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public createNoNotifcation = async (name: string): Promise<IClient> => {
|
public createNoNotifcation = async (name: string): Promise<IClient> => {
|
||||||
const client = await axios.post(`${config.get('url')}client`, {name});
|
const client = await axios.post(`${config.get('url')}client`, {name});
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
return client.data;
|
return client.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public create = async (name: string): Promise<void> => {
|
public create = async (name: string): Promise<void> => {
|
||||||
await this.createNoNotifcation(name);
|
await this.createNoNotifcation(name);
|
||||||
this.snack('Client added');
|
this.snack('Client added');
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,34 @@
|
||||||
import Grid from '@material-ui/core/Grid';
|
import React, {useEffect, useState} from 'react';
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
import Grid from '@mui/material/Grid';
|
||||||
import Paper from '@material-ui/core/Paper';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Table from '@material-ui/core/Table';
|
import Paper from '@mui/material/Paper';
|
||||||
import TableBody from '@material-ui/core/TableBody';
|
import Table from '@mui/material/Table';
|
||||||
import TableCell from '@material-ui/core/TableCell';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableHead from '@material-ui/core/TableHead';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableRow from '@material-ui/core/TableRow';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import Delete from '@material-ui/icons/Delete';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Edit from '@material-ui/icons/Edit';
|
import Delete from '@mui/icons-material/Delete';
|
||||||
import React, {Component, SFC} from 'react';
|
import Edit from '@mui/icons-material/Edit';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
import ConfirmDialog from '../common/ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import Button from '@material-ui/core/Button';
|
|
||||||
import AddClientDialog from './AddClientDialog';
|
import AddClientDialog from './AddClientDialog';
|
||||||
import UpdateDialog from './UpdateClientDialog';
|
import UpdateClientDialog from './UpdateClientDialog';
|
||||||
import {observer} from 'mobx-react';
|
|
||||||
import {observable} from 'mobx';
|
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {IClient} from '../types';
|
import {IClient} from '../types';
|
||||||
import CopyableSecret from '../common/CopyableSecret';
|
import CopyableSecret from '../common/CopyableSecret';
|
||||||
import {LastUsedCell} from '../common/LastUsedCell';
|
import {LastUsedCell} from '../common/LastUsedCell';
|
||||||
|
import {observer} from 'mobx-react';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
|
||||||
@observer
|
const Clients = observer(() => {
|
||||||
class Clients extends Component<Stores<'clientStore'>> {
|
const {clientStore} = useStores();
|
||||||
@observable
|
const [toDeleteClient, setToDeleteClient] = useState<IClient>();
|
||||||
private showDialog = false;
|
const [toUpdateClient, setToUpdateClient] = useState<IClient>();
|
||||||
@observable
|
const [createDialog, setCreateDialog] = useState<boolean>(false);
|
||||||
private deleteId: false | number = false;
|
|
||||||
@observable
|
|
||||||
private updateId: false | number = false;
|
|
||||||
|
|
||||||
public componentDidMount = () => this.props.clientStore.refresh();
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {
|
|
||||||
deleteId,
|
|
||||||
updateId,
|
|
||||||
showDialog,
|
|
||||||
props: {clientStore},
|
|
||||||
} = this;
|
|
||||||
const clients = clientStore.getItems();
|
const clients = clientStore.getItems();
|
||||||
|
|
||||||
|
useEffect(() => void clientStore.refresh(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultPage
|
<DefaultPage
|
||||||
title="Clients"
|
title="Clients"
|
||||||
|
|
@ -49,11 +37,11 @@ class Clients extends Component<Stores<'clientStore'>> {
|
||||||
id="create-client"
|
id="create-client"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => (this.showDialog = true)}>
|
onClick={() => setCreateDialog(true)}>
|
||||||
Create Client
|
Create Client
|
||||||
</Button>
|
</Button>
|
||||||
}>
|
}>
|
||||||
<Grid item xs={12}>
|
<Grid size={12}>
|
||||||
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
||||||
<Table id="client-table">
|
<Table id="client-table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|
@ -72,39 +60,38 @@ class Clients extends Component<Stores<'clientStore'>> {
|
||||||
name={client.name}
|
name={client.name}
|
||||||
value={client.token}
|
value={client.token}
|
||||||
lastUsed={client.lastUsed}
|
lastUsed={client.lastUsed}
|
||||||
fEdit={() => (this.updateId = client.id)}
|
fEdit={() => setToUpdateClient(client)}
|
||||||
fDelete={() => (this.deleteId = client.id)}
|
fDelete={() => setToDeleteClient(client)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
{showDialog && (
|
{createDialog && (
|
||||||
<AddClientDialog
|
<AddClientDialog
|
||||||
fClose={() => (this.showDialog = false)}
|
fClose={() => setCreateDialog(false)}
|
||||||
fOnSubmit={clientStore.create}
|
fOnSubmit={clientStore.create}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{updateId !== false && (
|
{toUpdateClient != null && (
|
||||||
<UpdateDialog
|
<UpdateClientDialog
|
||||||
fClose={() => (this.updateId = false)}
|
fClose={() => setToUpdateClient(undefined)}
|
||||||
fOnSubmit={(name) => clientStore.update(updateId, name)}
|
fOnSubmit={(name) => clientStore.update(toUpdateClient.id, name)}
|
||||||
initialName={clientStore.getByID(updateId).name}
|
initialName={toUpdateClient.name}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{deleteId !== false && (
|
{toDeleteClient != null && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Confirm Delete"
|
title="Confirm Delete"
|
||||||
text={'Delete ' + clientStore.getByID(deleteId).name + '?'}
|
text={'Delete ' + toDeleteClient.name + '?'}
|
||||||
fClose={() => (this.deleteId = false)}
|
fClose={() => setToDeleteClient(undefined)}
|
||||||
fOnSubmit={() => clientStore.remove(deleteId)}
|
fOnSubmit={() => clientStore.remove(toDeleteClient.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
interface IRowProps {
|
interface IRowProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -114,7 +101,7 @@ interface IRowProps {
|
||||||
fDelete: VoidFunction;
|
fDelete: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
|
const Row = ({name, value, lastUsed, fEdit, fDelete}: IRowProps) => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{name}</TableCell>
|
<TableCell>{name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -139,4 +126,4 @@ const Row: SFC<IRowProps> = ({name, value, lastUsed, fEdit, fDelete}) => (
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default inject('clientStore')(Clients);
|
export default Clients;
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,30 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import React, {useState} from 'react';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Button from '@mui/material/Button';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import TextField from '@mui/material/TextField';
|
||||||
import React, {Component} from 'react';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
fClose: VoidFunction;
|
fClose: VoidFunction;
|
||||||
fOnSubmit: (name: string) => void;
|
fOnSubmit: (name: string) => Promise<void>;
|
||||||
initialName: string;
|
initialName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
const UpdateClientDialog = ({fClose, fOnSubmit, initialName = ''}: IProps) => {
|
||||||
name: string;
|
const [name, setName] = useState(initialName);
|
||||||
}
|
|
||||||
|
|
||||||
export default class UpdateDialog extends Component<IProps, IState> {
|
const submitEnabled = name.length !== 0;
|
||||||
public state = {name: ''};
|
const submitAndClose = async () => {
|
||||||
|
await fOnSubmit(name);
|
||||||
constructor(props: IProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
name: props.initialName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {fClose, fOnSubmit} = this.props;
|
|
||||||
const {name} = this.state;
|
|
||||||
const submitEnabled = this.state.name.length !== 0;
|
|
||||||
const submitAndClose = () => {
|
|
||||||
fOnSubmit(name);
|
|
||||||
fClose();
|
fClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={true} onClose={fClose} aria-labelledby="form-dialog-title" id="client-dialog">
|
||||||
open={true}
|
|
||||||
onClose={fClose}
|
|
||||||
aria-labelledby="form-dialog-title"
|
|
||||||
id="client-dialog">
|
|
||||||
<DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
|
<DialogTitle id="form-dialog-title">Update a Client</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
|
|
@ -55,7 +38,7 @@ export default class UpdateDialog extends Component<IProps, IState> {
|
||||||
label="Name *"
|
label="Name *"
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={this.handleChange.bind(this, 'name')}
|
onChange={(e) => setName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -76,11 +59,6 @@ export default class UpdateDialog extends Component<IProps, IState> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private handleChange(propertyName: string, event: React.ChangeEvent<HTMLInputElement>) {
|
export default UpdateClientDialog;
|
||||||
const state = {};
|
|
||||||
state[propertyName] = event.target.value;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {action, observable} from 'mobx';
|
import {action, observable, makeObservable} from 'mobx';
|
||||||
|
|
||||||
interface HasID {
|
interface HasID {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -12,25 +12,21 @@ export interface IClearable {
|
||||||
* Base implementation for handling items with ids.
|
* Base implementation for handling items with ids.
|
||||||
*/
|
*/
|
||||||
export abstract class BaseStore<T extends HasID> implements IClearable {
|
export abstract class BaseStore<T extends HasID> implements IClearable {
|
||||||
@observable
|
|
||||||
protected items: T[] = [];
|
protected items: T[] = [];
|
||||||
|
|
||||||
protected abstract requestItems(): Promise<T[]>;
|
protected abstract requestItems(): Promise<T[]>;
|
||||||
|
|
||||||
protected abstract requestDelete(id: number): Promise<void>;
|
protected abstract requestDelete(id: number): Promise<void>;
|
||||||
|
|
||||||
@action
|
|
||||||
public remove = async (id: number): Promise<void> => {
|
public remove = async (id: number): Promise<void> => {
|
||||||
await this.requestDelete(id);
|
await this.requestDelete(id);
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public refresh = async (): Promise<void> => {
|
public refresh = async (): Promise<void> => {
|
||||||
this.items = await this.requestItems().then((items) => items || []);
|
this.items = await this.requestItems().then((items) => items || []);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public refreshIfMissing = async (id: number): Promise<void> => {
|
public refreshIfMissing = async (id: number): Promise<void> => {
|
||||||
if (this.getByIDOrUndefined(id) === undefined) {
|
if (this.getByIDOrUndefined(id) === undefined) {
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
|
|
@ -50,8 +46,18 @@ export abstract class BaseStore<T extends HasID> implements IClearable {
|
||||||
|
|
||||||
public getItems = (): T[] => this.items;
|
public getItems = (): T[] => this.items;
|
||||||
|
|
||||||
@action
|
|
||||||
public clear = (): void => {
|
public clear = (): void => {
|
||||||
this.items = [];
|
this.items = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
makeObservable<BaseStore<any>, 'items'>(this, {
|
||||||
|
items: observable,
|
||||||
|
remove: action,
|
||||||
|
refresh: action,
|
||||||
|
refreshIfMissing: action,
|
||||||
|
clear: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
interface ConnectionErrorBannerProps {
|
interface ConnectionErrorBannerProps {
|
||||||
height: number;
|
height: number;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,24 @@
|
||||||
import Paper from '@material-ui/core/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import {withStyles, WithStyles} from '@material-ui/core/styles';
|
import {makeStyles} from 'tss-react/mui';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
const styles = () => ({
|
const useStyles = makeStyles()(() => ({
|
||||||
paper: {
|
paper: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
interface IProps extends WithStyles<'paper'> {
|
interface IProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container: React.FC<IProps> = ({classes, children, style}) => (
|
const Container: React.FC<React.PropsWithChildren<IProps>> = ({children, style}) => {
|
||||||
|
const {classes} = useStyles();
|
||||||
|
return (
|
||||||
<Paper elevation={6} className={classes.paper} style={style}>
|
<Paper elevation={6} className={classes.paper} style={style}>
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default withStyles(styles)(Container);
|
export default Container;
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,22 @@
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Visibility from '@material-ui/icons/Visibility';
|
import Visibility from '@mui/icons-material/Visibility';
|
||||||
import Copy from '@material-ui/icons/FileCopyOutlined';
|
import Copy from '@mui/icons-material/FileCopyOutlined';
|
||||||
import VisibilityOff from '@material-ui/icons/VisibilityOff';
|
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||||
import React, {Component, CSSProperties} from 'react';
|
import React, {CSSProperties} from 'react';
|
||||||
import {Stores, inject} from '../inject';
|
import {useStores} from '../stores';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
value: string;
|
value: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
const CopyableSecret = ({value, style}: IProps) => {
|
||||||
visible: boolean;
|
const [visible, setVisible] = React.useState(false);
|
||||||
}
|
const text = visible ? value : '•••••••••••••••';
|
||||||
|
const {snackManager} = useStores();
|
||||||
class CopyableSecret extends Component<IProps & Stores<'snackManager'>, IState> {
|
const toggleVisibility = () => setVisible((b) => !b);
|
||||||
public state = {visible: false};
|
const copyToClipboard = async () => {
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {value, style} = this.props;
|
|
||||||
const text = this.state.visible ? value : '•••••••••••••••';
|
|
||||||
return (
|
|
||||||
<div style={style}>
|
|
||||||
<IconButton onClick={this.copyToClipboard} title="Copy to clipboard">
|
|
||||||
<Copy />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton onClick={this.toggleVisibility} className="toggle-visibility">
|
|
||||||
{this.state.visible ? <VisibilityOff /> : <Visibility />}
|
|
||||||
</IconButton>
|
|
||||||
<Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private toggleVisibility = () => this.setState({visible: !this.state.visible});
|
|
||||||
private copyToClipboard = async () => {
|
|
||||||
const {snackManager, value} = this.props;
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
snackManager.snack('Copied to clipboard');
|
snackManager.snack('Copied to clipboard');
|
||||||
|
|
@ -45,6 +25,17 @@ class CopyableSecret extends Component<IProps & Stores<'snackManager'>, IState>
|
||||||
snackManager.snack('Failed to copy to clipboard');
|
snackManager.snack('Failed to copy to clipboard');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<IconButton onClick={copyToClipboard} title="Copy to clipboard" size="large">
|
||||||
|
<Copy />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={toggleVisibility} className="toggle-visibility" size="large">
|
||||||
|
{visible ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
<Typography style={{fontFamily: 'monospace', fontSize: 16}}>{text}</Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default inject('snackManager')(CopyableSecret);
|
export default CopyableSecret;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Grid from '@material-ui/core/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import React, {FC} from 'react';
|
import React, {FC} from 'react';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
|
|
@ -8,10 +8,15 @@ interface IProps {
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultPage: FC<IProps> = ({title, rightControl, maxWidth = 700, children}) => (
|
const DefaultPage: FC<React.PropsWithChildren<IProps>> = ({
|
||||||
|
title,
|
||||||
|
rightControl,
|
||||||
|
maxWidth = 700,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
<main style={{margin: '0 auto', maxWidth}}>
|
<main style={{margin: '0 auto', maxWidth}}>
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
<Grid item xs={12} style={{display: 'flex', flexWrap: 'wrap'}}>
|
<Grid size={{xs: 12}} style={{display: 'flex', flexWrap: 'wrap'}}>
|
||||||
<Typography variant="h4" style={{flex: 1}}>
|
<Typography variant="h4" style={{flex: 1}}>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {Typography} from '@material-ui/core';
|
import {Typography} from '@mui/material';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TimeAgo from 'react-timeago';
|
import TimeAgo from 'react-timeago';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Grid from '@material-ui/core/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import DefaultPage from './DefaultPage';
|
import DefaultPage from './DefaultPage';
|
||||||
|
|
||||||
export default function LoadingSpinner() {
|
export default function LoadingSpinner() {
|
||||||
return (
|
return (
|
||||||
<DefaultPage title="" maxWidth={250}>
|
<DefaultPage title="" maxWidth={250}>
|
||||||
<Grid item xs={12} style={{textAlign: 'center'}}>
|
<Grid size={{xs: 12}} style={{textAlign: 'center'}}>
|
||||||
<CircularProgress size={40} />
|
<CircularProgress size={40} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@ import ReactMarkdown from 'react-markdown';
|
||||||
import gfm from 'remark-gfm';
|
import gfm from 'remark-gfm';
|
||||||
|
|
||||||
export const Markdown = ({children}: {children: string}) => (
|
export const Markdown = ({children}: {children: string}) => (
|
||||||
<ReactMarkdown plugins={[gfm]}>{children}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[gfm]}>{children}</ReactMarkdown>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {TextField, TextFieldProps} from '@material-ui/core';
|
import {TextField, TextFieldProps} from '@mui/material';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export interface NumberFieldProps {
|
export interface NumberFieldProps {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,22 @@
|
||||||
import Fab from '@material-ui/core/Fab';
|
import Fab from '@mui/material/Fab';
|
||||||
import KeyboardArrowUp from '@material-ui/icons/KeyboardArrowUp';
|
import KeyboardArrowUp from '@mui/icons-material/KeyboardArrowUp';
|
||||||
import React, {Component} from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
class ScrollUpButton extends Component {
|
const ScrollUpButton = () => {
|
||||||
state = {
|
const [state, setState] = React.useState({display: 'none', opacity: 0});
|
||||||
display: 'none',
|
React.useEffect(() => {
|
||||||
opacity: 0,
|
const scrollHandler = () => {
|
||||||
};
|
const currentScrollPos = Math.max(window.pageYOffset - 1000, 0);
|
||||||
componentDidMount() {
|
const opacity = Math.min(currentScrollPos / 1000, 1);
|
||||||
window.addEventListener('scroll', this.scrollHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
window.removeEventListener('scroll', this.scrollHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollHandler = () => {
|
|
||||||
const currentScrollPos = window.pageYOffset;
|
|
||||||
const opacity = Math.min(currentScrollPos / 500, 1);
|
|
||||||
const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity};
|
const nextState = {display: currentScrollPos > 0 ? 'inherit' : 'none', opacity};
|
||||||
if (this.state.display !== nextState.display || this.state.opacity !== nextState.opacity) {
|
if (state.display !== nextState.display || state.opacity !== nextState.opacity) {
|
||||||
this.setState(nextState);
|
setState(nextState);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
window.addEventListener('scroll', scrollHandler);
|
||||||
|
return () => window.removeEventListener('scroll', scrollHandler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
public render() {
|
|
||||||
return (
|
return (
|
||||||
<Fab
|
<Fab
|
||||||
color="primary"
|
color="primary"
|
||||||
|
|
@ -33,16 +25,13 @@ class ScrollUpButton extends Component {
|
||||||
bottom: '30px',
|
bottom: '30px',
|
||||||
right: '30px',
|
right: '30px',
|
||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
display: this.state.display,
|
display: state.display,
|
||||||
opacity: this.state.opacity,
|
opacity: state.opacity,
|
||||||
}}
|
}}
|
||||||
onClick={this.scrollUp}>
|
onClick={() => window.scrollTo(0, 0)}>
|
||||||
<KeyboardArrowUp />
|
<KeyboardArrowUp />
|
||||||
</Fab>
|
</Fab>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
private scrollUp = () => window.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ScrollUpButton;
|
export default ScrollUpButton;
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,27 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import React, {useState} from 'react';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Button from '@mui/material/Button';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import TextField from '@mui/material/TextField';
|
||||||
import React, {Component} from 'react';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import {observable} from 'mobx';
|
|
||||||
import {observer} from 'mobx-react';
|
import {observer} from 'mobx-react';
|
||||||
import {inject, Stores} from '../inject';
|
import {useStores} from '../stores';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
fClose: VoidFunction;
|
fClose: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
const SettingsDialog = observer(({fClose}: IProps) => {
|
||||||
class SettingsDialog extends Component<IProps & Stores<'currentUser'>> {
|
const [pass, setPass] = useState('');
|
||||||
@observable
|
const {currentUser} = useStores();
|
||||||
private pass = '';
|
|
||||||
|
|
||||||
public render() {
|
const submitAndClose = async () => {
|
||||||
const {pass} = this;
|
|
||||||
const {fClose, currentUser} = this.props;
|
|
||||||
const submitAndClose = () => {
|
|
||||||
currentUser.changePassword(pass);
|
currentUser.changePassword(pass);
|
||||||
fClose();
|
fClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={true}
|
open={true}
|
||||||
|
|
@ -41,7 +37,7 @@ class SettingsDialog extends Component<IProps & Stores<'currentUser'>> {
|
||||||
type="password"
|
type="password"
|
||||||
label="New Password *"
|
label="New Password *"
|
||||||
value={pass}
|
value={pass}
|
||||||
onChange={(e) => (this.pass = e.target.value)}
|
onChange={(e) => setPass(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -62,7 +58,6 @@ class SettingsDialog extends Component<IProps & Stores<'currentUser'>> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export default inject('currentUser')(SettingsDialog);
|
export default SettingsDialog;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ export interface IConfig {
|
||||||
version: IVersion;
|
version: IVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
config?: Partial<IConfig>;
|
config?: Partial<IConfig>;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import {createRoot} from 'react-dom/client';
|
||||||
import 'typeface-roboto';
|
import 'typeface-roboto';
|
||||||
import {initAxios} from './apiAuth';
|
import {initAxios} from './apiAuth';
|
||||||
import * as config from './config';
|
import * as config from './config';
|
||||||
|
|
@ -9,14 +9,12 @@ import {CurrentUser} from './CurrentUser';
|
||||||
import {AppStore} from './application/AppStore';
|
import {AppStore} from './application/AppStore';
|
||||||
import {WebSocketStore} from './message/WebSocketStore';
|
import {WebSocketStore} from './message/WebSocketStore';
|
||||||
import {SnackManager} from './snack/SnackManager';
|
import {SnackManager} from './snack/SnackManager';
|
||||||
import {InjectProvider, StoreMapping} from './inject';
|
|
||||||
import {UserStore} from './user/UserStore';
|
import {UserStore} from './user/UserStore';
|
||||||
import {MessagesStore} from './message/MessagesStore';
|
import {MessagesStore} from './message/MessagesStore';
|
||||||
import {ClientStore} from './client/ClientStore';
|
import {ClientStore} from './client/ClientStore';
|
||||||
import {PluginStore} from './plugin/PluginStore';
|
import {PluginStore} from './plugin/PluginStore';
|
||||||
import {registerReactions} from './reactions';
|
import {registerReactions} from './reactions';
|
||||||
|
import {StoreContext, StoreMapping} from './stores';
|
||||||
const devUrl = 'http://localhost:3000/';
|
|
||||||
|
|
||||||
const {port, hostname, protocol, pathname} = window.location;
|
const {port, hostname, protocol, pathname} = window.location;
|
||||||
const slashes = protocol.concat('//');
|
const slashes = protocol.concat('//');
|
||||||
|
|
@ -50,12 +48,7 @@ const initStores = (): StoreMapping => {
|
||||||
};
|
};
|
||||||
|
|
||||||
(function clientJS() {
|
(function clientJS() {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
config.set('url', prodUrl);
|
config.set('url', prodUrl);
|
||||||
} else {
|
|
||||||
config.set('url', devUrl);
|
|
||||||
config.set('register', true);
|
|
||||||
}
|
|
||||||
const stores = initStores();
|
const stores = initStores();
|
||||||
initAxios(stores.currentUser, stores.snackManager.snack);
|
initAxios(stores.currentUser, stores.snackManager.snack);
|
||||||
|
|
||||||
|
|
@ -67,11 +60,10 @@ const initStores = (): StoreMapping => {
|
||||||
stores.wsStore.close();
|
stores.wsStore.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
ReactDOM.render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<InjectProvider stores={stores}>
|
<StoreContext.Provider value={stores}>
|
||||||
<Layout />
|
<Layout />
|
||||||
</InjectProvider>,
|
</StoreContext.Provider>
|
||||||
document.getElementById('root')
|
|
||||||
);
|
);
|
||||||
unregister();
|
unregister();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,49 @@
|
||||||
import AppBar from '@material-ui/core/AppBar';
|
import AppBar from '@mui/material/AppBar';
|
||||||
import Button from '@material-ui/core/Button';
|
import Button, {ButtonProps} from '@mui/material/Button';
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import {createStyles, Theme, WithStyles, withStyles} from '@material-ui/core/styles';
|
import {Theme} from '@mui/material/styles';
|
||||||
import Toolbar from '@material-ui/core/Toolbar';
|
import {makeStyles} from 'tss-react/mui';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
import AccountCircle from '@material-ui/icons/AccountCircle';
|
import Typography from '@mui/material/Typography';
|
||||||
import Chat from '@material-ui/icons/Chat';
|
import AccountCircle from '@mui/icons-material/AccountCircle';
|
||||||
import DevicesOther from '@material-ui/icons/DevicesOther';
|
import Chat from '@mui/icons-material/Chat';
|
||||||
import ExitToApp from '@material-ui/icons/ExitToApp';
|
import DevicesOther from '@mui/icons-material/DevicesOther';
|
||||||
import Highlight from '@material-ui/icons/Highlight';
|
import ExitToApp from '@mui/icons-material/ExitToApp';
|
||||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
import Highlight from '@mui/icons-material/Highlight';
|
||||||
import MenuIcon from '@material-ui/icons/Menu';
|
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||||
import Apps from '@material-ui/icons/Apps';
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
import SupervisorAccount from '@material-ui/icons/SupervisorAccount';
|
import Apps from '@mui/icons-material/Apps';
|
||||||
import React, {Component, CSSProperties} from 'react';
|
import SupervisorAccount from '@mui/icons-material/SupervisorAccount';
|
||||||
|
import React, {CSSProperties} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {observer} from 'mobx-react';
|
import {useMediaQuery} from '@mui/material';
|
||||||
import {Hidden, PropTypes, withWidth} from '@material-ui/core';
|
|
||||||
import {Breakpoint} from '@material-ui/core/styles/createBreakpoints';
|
|
||||||
|
|
||||||
const styles = (theme: Theme) =>
|
const useStyles = makeStyles()((theme: Theme) => ({
|
||||||
createStyles({
|
|
||||||
appBar: {
|
appBar: {
|
||||||
zIndex: theme.zIndex.drawer + 1,
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
[theme.breakpoints.down('xs')]: {
|
[theme.breakpoints.down('sm')]: {
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
[theme.breakpoints.down('xs')]: {
|
[theme.breakpoints.down('sm')]: {
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
menuButtons: {
|
menuButtons: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
[theme.breakpoints.down('sm')]: {
|
[theme.breakpoints.down('md')]: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
[theme.breakpoints.down('xs')]: {
|
[theme.breakpoints.down('sm')]: {
|
||||||
flexBasis: '100%',
|
flexBasis: '100%',
|
||||||
marginTop: 5,
|
marginTop: 5,
|
||||||
order: 1,
|
order: 1,
|
||||||
|
height: 50,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
|
|
@ -60,11 +60,9 @@ const styles = (theme: Theme) =>
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
type Styles = WithStyles<'link' | 'menuButtons' | 'toolbar' | 'titleName' | 'title' | 'appBar'>;
|
interface IProps {
|
||||||
|
|
||||||
interface IProps extends Styles {
|
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
|
|
@ -73,15 +71,10 @@ interface IProps extends Styles {
|
||||||
showSettings: VoidFunction;
|
showSettings: VoidFunction;
|
||||||
logout: VoidFunction;
|
logout: VoidFunction;
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
width: Breakpoint;
|
|
||||||
setNavOpen: (open: boolean) => void;
|
setNavOpen: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
const Header = ({
|
||||||
class Header extends Component<IProps> {
|
|
||||||
public render() {
|
|
||||||
const {
|
|
||||||
classes,
|
|
||||||
version,
|
version,
|
||||||
name,
|
name,
|
||||||
loggedIn,
|
loggedIn,
|
||||||
|
|
@ -90,13 +83,15 @@ class Header extends Component<IProps> {
|
||||||
logout,
|
logout,
|
||||||
style,
|
style,
|
||||||
setNavOpen,
|
setNavOpen,
|
||||||
width,
|
showSettings,
|
||||||
} = this.props;
|
}: IProps) => {
|
||||||
|
const {classes} = useStyles();
|
||||||
const position = width === 'xs' ? 'sticky' : 'fixed';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position={position} style={style} className={classes.appBar}>
|
<AppBar
|
||||||
|
sx={{position: {xs: 'sticky', sm: 'fixed'}}}
|
||||||
|
style={style}
|
||||||
|
className={classes.appBar}>
|
||||||
<Toolbar className={classes.toolbar}>
|
<Toolbar className={classes.toolbar}>
|
||||||
<div className={classes.title}>
|
<div className={classes.title}>
|
||||||
<Link to="/" className={classes.link}>
|
<Link to="/" className={classes.link}>
|
||||||
|
|
@ -112,9 +107,17 @@ class Header extends Component<IProps> {
|
||||||
</Typography>
|
</Typography>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{loggedIn && this.renderButtons(name, admin, logout, width, setNavOpen)}
|
{loggedIn && (
|
||||||
|
<Buttons
|
||||||
|
admin={admin}
|
||||||
|
name={name}
|
||||||
|
logout={logout}
|
||||||
|
setNavOpen={setNavOpen}
|
||||||
|
showSettings={showSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<IconButton onClick={toggleTheme} color="inherit">
|
<IconButton onClick={toggleTheme} color="inherit" size="large">
|
||||||
<Highlight />
|
<Highlight />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
|
@ -123,7 +126,7 @@ class Header extends Component<IProps> {
|
||||||
className={classes.link}
|
className={classes.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer">
|
rel="noopener noreferrer">
|
||||||
<IconButton color="inherit">
|
<IconButton color="inherit" size="large">
|
||||||
<GitHubIcon />
|
<GitHubIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -131,62 +134,51 @@ class Header extends Component<IProps> {
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const Buttons = ({
|
||||||
|
showSettings,
|
||||||
|
name,
|
||||||
|
admin,
|
||||||
|
logout,
|
||||||
|
setNavOpen,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
admin: boolean;
|
||||||
|
logout: VoidFunction;
|
||||||
|
setNavOpen: (open: boolean) => void;
|
||||||
|
showSettings: VoidFunction;
|
||||||
|
}) => {
|
||||||
|
const {classes} = useStyles();
|
||||||
|
|
||||||
private renderButtons(
|
|
||||||
name: string,
|
|
||||||
admin: boolean,
|
|
||||||
logout: VoidFunction,
|
|
||||||
width: Breakpoint,
|
|
||||||
setNavOpen: (open: boolean) => void
|
|
||||||
) {
|
|
||||||
const {classes, showSettings} = this.props;
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.menuButtons}>
|
<div className={classes.menuButtons}>
|
||||||
<Hidden smUp implementation="css">
|
|
||||||
<ResponsiveButton
|
<ResponsiveButton
|
||||||
|
sx={{display: {sm: 'none', xs: 'block'}}}
|
||||||
icon={<MenuIcon />}
|
icon={<MenuIcon />}
|
||||||
onClick={() => setNavOpen(true)}
|
onClick={() => setNavOpen(true)}
|
||||||
label="menu"
|
label="menu"
|
||||||
width={width}
|
|
||||||
color="inherit"
|
color="inherit"
|
||||||
/>
|
/>
|
||||||
</Hidden>
|
|
||||||
{admin && (
|
{admin && (
|
||||||
<Link className={classes.link} to="/users" id="navigate-users">
|
<Link className={classes.link} to="/users" id="navigate-users">
|
||||||
<ResponsiveButton
|
<ResponsiveButton icon={<SupervisorAccount />} label="users" color="inherit" />
|
||||||
icon={<SupervisorAccount />}
|
|
||||||
label="users"
|
|
||||||
width={width}
|
|
||||||
color="inherit"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<Link className={classes.link} to="/applications" id="navigate-apps">
|
<Link className={classes.link} to="/applications" id="navigate-apps">
|
||||||
<ResponsiveButton icon={<Chat />} label="apps" width={width} color="inherit" />
|
<ResponsiveButton icon={<Chat />} label="apps" color="inherit" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link className={classes.link} to="/clients" id="navigate-clients">
|
<Link className={classes.link} to="/clients" id="navigate-clients">
|
||||||
<ResponsiveButton
|
<ResponsiveButton icon={<DevicesOther />} label="clients" color="inherit" />
|
||||||
icon={<DevicesOther />}
|
|
||||||
label="clients"
|
|
||||||
width={width}
|
|
||||||
color="inherit"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link className={classes.link} to="/plugins" id="navigate-plugins">
|
<Link className={classes.link} to="/plugins" id="navigate-plugins">
|
||||||
<ResponsiveButton
|
<ResponsiveButton icon={<Apps />} label="plugins" color="inherit" />
|
||||||
icon={<Apps />}
|
|
||||||
label="plugins"
|
|
||||||
width={width}
|
|
||||||
color="inherit"
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
<ResponsiveButton
|
<ResponsiveButton
|
||||||
icon={<AccountCircle />}
|
icon={<AccountCircle />}
|
||||||
label={name}
|
label={name}
|
||||||
onClick={showSettings}
|
onClick={showSettings}
|
||||||
id="changepw"
|
id="changepw"
|
||||||
width={width}
|
|
||||||
color="inherit"
|
color="inherit"
|
||||||
/>
|
/>
|
||||||
<ResponsiveButton
|
<ResponsiveButton
|
||||||
|
|
@ -194,24 +186,27 @@ class Header extends Component<IProps> {
|
||||||
label="Logout"
|
label="Logout"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
id="logout"
|
id="logout"
|
||||||
width={width}
|
|
||||||
color="inherit"
|
color="inherit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const ResponsiveButton: React.FC<{
|
const ResponsiveButton: React.FC<{
|
||||||
width: Breakpoint;
|
color: 'inherit';
|
||||||
color: PropTypes.Color;
|
sx?: ButtonProps['sx'];
|
||||||
label: string;
|
label: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}> = ({width, icon, label, ...rest}) => {
|
}> = ({icon, label, ...rest}) => {
|
||||||
if (width === 'xs' || width === 'sm') {
|
const matches = useMediaQuery('(max-width:1000px)');
|
||||||
return <IconButton {...rest}>{icon}</IconButton>;
|
if (matches) {
|
||||||
|
return (
|
||||||
|
<IconButton {...rest} size="large">
|
||||||
|
{icon}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button startIcon={icon} {...rest}>
|
<Button startIcon={icon} {...rest}>
|
||||||
|
|
@ -220,4 +215,4 @@ const ResponsiveButton: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withWidth()(withStyles(styles, {withTheme: true})(Header));
|
export default Header;
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,48 @@
|
||||||
import {createMuiTheme, MuiThemeProvider, Theme, WithStyles, withStyles} from '@material-ui/core';
|
import {createTheme, ThemeProvider, StyledEngineProvider, Theme} from '@mui/material';
|
||||||
import CssBaseline from '@material-ui/core/CssBaseline';
|
import {makeStyles} from 'tss-react/mui';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {HashRouter, Redirect, Route, Switch} from 'react-router-dom';
|
import {HashRouter, Navigate, Route, Routes} from 'react-router-dom';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import LoadingSpinner from '../common/LoadingSpinner';
|
|
||||||
import Navigation from './Navigation';
|
import Navigation from './Navigation';
|
||||||
import ScrollUpButton from '../common/ScrollUpButton';
|
import ScrollUpButton from '../common/ScrollUpButton';
|
||||||
import SettingsDialog from '../common/SettingsDialog';
|
import SettingsDialog from '../common/SettingsDialog';
|
||||||
import SnackBarHandler from '../snack/SnackBarHandler';
|
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import Applications from '../application/Applications';
|
import Applications from '../application/Applications';
|
||||||
import Clients from '../client/Clients';
|
import Clients from '../client/Clients';
|
||||||
import Plugins from '../plugin/Plugins';
|
import Plugins from '../plugin/Plugins';
|
||||||
import PluginDetailView from '../plugin/PluginDetailView';
|
|
||||||
import Login from '../user/Login';
|
import Login from '../user/Login';
|
||||||
import Messages from '../message/Messages';
|
import Messages from '../message/Messages';
|
||||||
import Users from '../user/Users';
|
import Users from '../user/Users';
|
||||||
import {observer} from 'mobx-react';
|
import {observer} from 'mobx-react';
|
||||||
import {observable} from 'mobx';
|
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner';
|
import {ConnectionErrorBanner} from '../common/ConnectionErrorBanner';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
import {SnackbarProvider} from 'notistack';
|
||||||
|
import LoadingSpinner from '../common/LoadingSpinner';
|
||||||
|
|
||||||
const styles = (theme: Theme) => ({
|
const useStyles = makeStyles()((theme: Theme) => ({
|
||||||
content: {
|
content: {
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
marginTop: 64,
|
marginTop: 64,
|
||||||
padding: theme.spacing(4),
|
padding: theme.spacing(4),
|
||||||
width: '100%',
|
width: '100%',
|
||||||
[theme.breakpoints.down('xs')]: {
|
[theme.breakpoints.down('sm')]: {
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
const localStorageThemeKey = 'gotify-theme';
|
const localStorageThemeKey = 'gotify-theme';
|
||||||
type ThemeKey = 'dark' | 'light';
|
type ThemeKey = 'dark' | 'light';
|
||||||
const themeMap: Record<ThemeKey, Theme> = {
|
const themeMap: Record<ThemeKey, Theme> = {
|
||||||
light: createMuiTheme({
|
light: createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
type: 'light',
|
mode: 'light',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
dark: createMuiTheme({
|
dark: createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
type: 'dark',
|
mode: 'dark',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
@ -51,50 +50,44 @@ const themeMap: Record<ThemeKey, Theme> = {
|
||||||
const isThemeKey = (value: string | null): value is ThemeKey =>
|
const isThemeKey = (value: string | null): value is ThemeKey =>
|
||||||
value === 'light' || value === 'dark';
|
value === 'light' || value === 'dark';
|
||||||
|
|
||||||
@observer
|
const Layout = observer(() => {
|
||||||
class Layout extends React.Component<
|
|
||||||
WithStyles<'content'> & Stores<'currentUser' | 'snackManager'>
|
|
||||||
> {
|
|
||||||
@observable
|
|
||||||
private currentTheme: ThemeKey = 'dark';
|
|
||||||
@observable
|
|
||||||
private showSettings = false;
|
|
||||||
@observable
|
|
||||||
private navOpen = false;
|
|
||||||
|
|
||||||
private setNavOpen(open: boolean) {
|
|
||||||
this.navOpen = open;
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount() {
|
|
||||||
const localStorageTheme = window.localStorage.getItem(localStorageThemeKey);
|
|
||||||
if (isThemeKey(localStorageTheme)) {
|
|
||||||
this.currentTheme = localStorageTheme;
|
|
||||||
} else {
|
|
||||||
window.localStorage.setItem(localStorageThemeKey, this.currentTheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {showSettings, currentTheme} = this;
|
|
||||||
const {
|
const {
|
||||||
classes,
|
|
||||||
currentUser: {
|
currentUser: {
|
||||||
loggedIn,
|
loggedIn,
|
||||||
authenticating,
|
|
||||||
user: {name, admin},
|
user: {name, admin},
|
||||||
logout,
|
logout,
|
||||||
tryReconnect,
|
tryReconnect,
|
||||||
connectionErrorMessage,
|
connectionErrorMessage,
|
||||||
|
refreshKey,
|
||||||
},
|
},
|
||||||
} = this.props;
|
} = useStores();
|
||||||
|
const {classes} = useStyles();
|
||||||
|
const [currentTheme, setCurrentTheme] = React.useState<ThemeKey>(() => {
|
||||||
|
const stored = window.localStorage.getItem(localStorageThemeKey);
|
||||||
|
return isThemeKey(stored) ? stored : 'dark';
|
||||||
|
});
|
||||||
const theme = themeMap[currentTheme];
|
const theme = themeMap[currentTheme];
|
||||||
const loginRoute = () => (loggedIn ? <Redirect to="/" /> : <Login />);
|
|
||||||
const {version} = config.get('version');
|
const {version} = config.get('version');
|
||||||
|
const [navOpen, setNavOpen] = React.useState(false);
|
||||||
|
const [showSettings, setShowSettings] = React.useState(false);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const next = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
setCurrentTheme(next);
|
||||||
|
localStorage.setItem(localStorageThemeKey, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const authed = (children: React.ReactNode) => (
|
||||||
|
<RequireAuth loggedIn={loggedIn}>{children}</RequireAuth>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MuiThemeProvider theme={theme}>
|
<StyledEngineProvider injectFirst>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<div>
|
{/* This forces all components to fully rerender including useEffects.
|
||||||
|
The refreshKey is updated when store data was cleaned and pages should refetch their data. */}
|
||||||
|
<div key={refreshKey}>
|
||||||
{!connectionErrorMessage ? null : (
|
{!connectionErrorMessage ? null : (
|
||||||
<ConnectionErrorBanner
|
<ConnectionErrorBanner
|
||||||
height={64}
|
height={64}
|
||||||
|
|
@ -105,65 +98,79 @@ class Layout extends React.Component<
|
||||||
<div style={{display: 'flex', flexDirection: 'column'}}>
|
<div style={{display: 'flex', flexDirection: 'column'}}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Header
|
<Header
|
||||||
style={{top: !connectionErrorMessage ? 0 : 64}}
|
|
||||||
admin={admin}
|
admin={admin}
|
||||||
name={name}
|
name={name}
|
||||||
|
style={{top: !connectionErrorMessage ? 0 : 64}}
|
||||||
version={version}
|
version={version}
|
||||||
loggedIn={loggedIn}
|
loggedIn={loggedIn}
|
||||||
toggleTheme={this.toggleTheme.bind(this)}
|
toggleTheme={toggleTheme}
|
||||||
showSettings={() => (this.showSettings = true)}
|
showSettings={() => setShowSettings(true)}
|
||||||
logout={logout}
|
logout={logout}
|
||||||
setNavOpen={this.setNavOpen.bind(this)}
|
setNavOpen={setNavOpen}
|
||||||
/>
|
/>
|
||||||
<div style={{display: 'flex'}}>
|
<div style={{display: 'flex'}}>
|
||||||
<Navigation
|
<Navigation
|
||||||
loggedIn={loggedIn}
|
loggedIn={loggedIn}
|
||||||
navOpen={this.navOpen}
|
navOpen={navOpen}
|
||||||
setNavOpen={this.setNavOpen.bind(this)}
|
setNavOpen={setNavOpen}
|
||||||
/>
|
/>
|
||||||
<main className={classes.content}>
|
<main className={classes.content}>
|
||||||
<Switch>
|
<Routes>
|
||||||
{authenticating ? (
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/">
|
<Route path="/" element={authed(<Messages />)} />
|
||||||
<LoadingSpinner />
|
<Route
|
||||||
</Route>
|
path="/messages/:id"
|
||||||
) : null}
|
element={authed(<Messages />)}
|
||||||
<Route exact path="/login" render={loginRoute} />
|
/>
|
||||||
{loggedIn ? null : <Redirect to="/login" />}
|
|
||||||
<Route exact path="/" component={Messages} />
|
|
||||||
<Route exact path="/messages/:id" component={Messages} />
|
|
||||||
<Route
|
<Route
|
||||||
exact
|
|
||||||
path="/applications"
|
path="/applications"
|
||||||
component={Applications}
|
element={authed(<Applications />)}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/clients" component={Clients} />
|
<Route path="/clients" element={authed(<Clients />)} />
|
||||||
<Route exact path="/users" component={Users} />
|
<Route path="/users" element={authed(<Users />)} />
|
||||||
<Route exact path="/plugins" component={Plugins} />
|
<Route path="/plugins" element={authed(<Plugins />)} />
|
||||||
<Route
|
<Route
|
||||||
exact
|
|
||||||
path="/plugins/:id"
|
path="/plugins/:id"
|
||||||
component={PluginDetailView}
|
element={authed(
|
||||||
|
<Lazy
|
||||||
|
component={() =>
|
||||||
|
import('../plugin/PluginDetailView')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
)}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<SettingsDialog fClose={() => (this.showSettings = false)} />
|
<SettingsDialog fClose={() => setShowSettings(false)} />
|
||||||
)}
|
)}
|
||||||
<ScrollUpButton />
|
<ScrollUpButton />
|
||||||
<SnackBarHandler />
|
<SnackbarProvider />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</MuiThemeProvider>
|
</ThemeProvider>
|
||||||
|
</StyledEngineProvider>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
private toggleTheme() {
|
// eslint-disable-next-line
|
||||||
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
const Lazy = ({component}: {component: () => Promise<{default: React.ComponentType<any>}>}) => {
|
||||||
localStorage.setItem(localStorageThemeKey, this.currentTheme);
|
const Component = React.lazy(component);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withStyles(styles, {withTheme: true})(inject('currentUser', 'snackManager')(Layout));
|
return (
|
||||||
|
<React.Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Component />
|
||||||
|
</React.Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RequireAuth: React.FC<React.PropsWithChildren<{loggedIn: boolean}>> = ({
|
||||||
|
children,
|
||||||
|
loggedIn,
|
||||||
|
}) => {
|
||||||
|
return loggedIn ? <>{children}</> : <Navigate replace={true} to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import Divider from '@material-ui/core/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import Drawer from '@material-ui/core/Drawer';
|
import Drawer from '@mui/material/Drawer';
|
||||||
import {StyleRules, Theme, WithStyles, withStyles} from '@material-ui/core/styles';
|
import {Theme} from '@mui/material/styles';
|
||||||
import React, {Component} from 'react';
|
import React from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {observer} from 'mobx-react';
|
import {observer} from 'mobx-react';
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {mayAllowPermission, requestPermission} from '../snack/browserNotification';
|
import {mayAllowPermission, requestPermission} from '../snack/browserNotification';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Hidden,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
ListItemText,
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
Avatar,
|
Avatar,
|
||||||
} from '@material-ui/core';
|
ListItemButton,
|
||||||
import {DrawerProps} from '@material-ui/core/Drawer/Drawer';
|
} from '@mui/material';
|
||||||
import CloseIcon from '@material-ui/icons/Close';
|
import {DrawerProps} from '@mui/material/Drawer/Drawer';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import {makeStyles} from 'tss-react/mui';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
|
||||||
const styles = (theme: Theme): StyleRules<'root' | 'drawerPaper' | 'toolbar' | 'link'> => ({
|
const useStyles = makeStyles()((theme: Theme) => ({
|
||||||
root: {
|
root: {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
|
@ -29,14 +29,13 @@ const styles = (theme: Theme): StyleRules<'root' | 'drawerPaper' | 'toolbar' | '
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
},
|
},
|
||||||
toolbar: theme.mixins.toolbar,
|
// eslint-disable-next-line
|
||||||
|
toolbar: theme.mixins.toolbar as any,
|
||||||
link: {
|
link: {
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
type Styles = WithStyles<'root' | 'drawerPaper' | 'toolbar' | 'link'>;
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
|
|
@ -44,16 +43,11 @@ interface IProps {
|
||||||
setNavOpen: (open: boolean) => void;
|
setNavOpen: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
const Navigation = observer(({loggedIn, navOpen, setNavOpen}: IProps) => {
|
||||||
class Navigation extends Component<
|
const [showRequestNotification, setShowRequestNotification] =
|
||||||
IProps & Styles & Stores<'appStore'>,
|
React.useState(mayAllowPermission);
|
||||||
{showRequestNotification: boolean}
|
const {classes} = useStyles();
|
||||||
> {
|
const {appStore} = useStores();
|
||||||
public state = {showRequestNotification: mayAllowPermission()};
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {classes, loggedIn, appStore, navOpen, setNavOpen} = this.props;
|
|
||||||
const {showRequestNotification} = this.state;
|
|
||||||
const apps = appStore.getItems();
|
const apps = appStore.getItems();
|
||||||
|
|
||||||
const userApps =
|
const userApps =
|
||||||
|
|
@ -65,7 +59,7 @@ class Navigation extends Component<
|
||||||
className={`${classes.link} item`}
|
className={`${classes.link} item`}
|
||||||
to={'/messages/' + app.id}
|
to={'/messages/' + app.id}
|
||||||
key={app.id}>
|
key={app.id}>
|
||||||
<ListItem button>
|
<ListItemButton>
|
||||||
<ListItemAvatar style={{minWidth: 42}}>
|
<ListItemAvatar style={{minWidth: 42}}>
|
||||||
<Avatar
|
<Avatar
|
||||||
style={{width: 32, height: 32}}
|
style={{width: 32, height: 32}}
|
||||||
|
|
@ -74,17 +68,17 @@ class Navigation extends Component<
|
||||||
/>
|
/>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={app.name} />
|
<ListItemText primary={app.name} />
|
||||||
</ListItem>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
));
|
));
|
||||||
|
|
||||||
const placeholderItems = [
|
const placeholderItems = [
|
||||||
<ListItem button disabled key={-1}>
|
<ListItemButton disabled key={-1}>
|
||||||
<ListItemText primary="Some Server" />
|
<ListItemText primary="Some Server" />
|
||||||
</ListItem>,
|
</ListItemButton>,
|
||||||
<ListItem button disabled key={-2}>
|
<ListItemButton disabled key={-2}>
|
||||||
<ListItemText primary="A Raspberry PI" />
|
<ListItemText primary="A Raspberry PI" />
|
||||||
</ListItem>,
|
</ListItemButton>,
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -95,9 +89,9 @@ class Navigation extends Component<
|
||||||
id="message-navigation">
|
id="message-navigation">
|
||||||
<div className={classes.toolbar} />
|
<div className={classes.toolbar} />
|
||||||
<Link className={classes.link} to="/" onClick={() => setNavOpen(false)}>
|
<Link className={classes.link} to="/" onClick={() => setNavOpen(false)}>
|
||||||
<ListItem button disabled={!loggedIn} className="all">
|
<ListItemButton disabled={!loggedIn} className="all">
|
||||||
<ListItemText primary="All Messages" />
|
<ListItemText primary="All Messages" />
|
||||||
</ListItem>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div>{loggedIn ? userApps : placeholderItems}</div>
|
<div>{loggedIn ? userApps : placeholderItems}</div>
|
||||||
|
|
@ -107,7 +101,7 @@ class Navigation extends Component<
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
requestPermission();
|
requestPermission();
|
||||||
this.setState({showRequestNotification: false});
|
setShowRequestNotification(false);
|
||||||
}}>
|
}}>
|
||||||
Enable Notifications
|
Enable Notifications
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -115,27 +109,26 @@ class Navigation extends Component<
|
||||||
</Typography>
|
</Typography>
|
||||||
</ResponsiveDrawer>
|
</ResponsiveDrawer>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const ResponsiveDrawer: React.FC<
|
const ResponsiveDrawer: React.FC<
|
||||||
DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void}
|
DrawerProps & {navOpen: boolean; setNavOpen: (open: boolean) => void}
|
||||||
> = ({navOpen, setNavOpen, children, ...rest}) => (
|
> = ({navOpen, setNavOpen, children, ...rest}) => (
|
||||||
<>
|
<>
|
||||||
<Hidden smUp implementation="css">
|
<Drawer
|
||||||
<Drawer variant="temporary" open={navOpen} {...rest}>
|
sx={{display: {sm: 'none', xs: 'block'}}}
|
||||||
<IconButton onClick={() => setNavOpen(false)}>
|
variant="temporary"
|
||||||
|
open={navOpen}
|
||||||
|
{...rest}>
|
||||||
|
<IconButton onClick={() => setNavOpen(false)} size="large">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{children}
|
{children}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Hidden>
|
<Drawer sx={{display: {xs: 'none', sm: 'block'}}} variant="permanent" {...rest}>
|
||||||
<Hidden xsDown implementation="css">
|
|
||||||
<Drawer variant="permanent" {...rest}>
|
|
||||||
{children}
|
{children}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Hidden>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default withStyles(styles, {withTheme: true})(inject('appStore')(Navigation));
|
export default Navigation;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import {Button} from '@material-ui/core';
|
import {Button, Theme, useMediaQuery, useTheme} from '@mui/material';
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import {createStyles, Theme, withStyles, WithStyles} from '@material-ui/core/styles';
|
import {makeStyles} from 'tss-react/mui';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import {ExpandLess, ExpandMore} from '@material-ui/icons';
|
import {ExpandLess, ExpandMore} from '@mui/icons-material';
|
||||||
import Delete from '@material-ui/icons/Delete';
|
import Delete from '@mui/icons-material/Delete';
|
||||||
import React, {RefObject} from 'react';
|
import React from 'react';
|
||||||
import TimeAgo from 'react-timeago';
|
import TimeAgo from 'react-timeago';
|
||||||
import Container from '../common/Container';
|
import Container from '../common/Container';
|
||||||
import {Markdown} from '../common/Markdown';
|
import {Markdown} from '../common/Markdown';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import {IMessageExtras} from '../types';
|
import {IMessageExtras} from '../types';
|
||||||
import {contentType, RenderMode} from './extras';
|
import {contentType, RenderMode} from './extras';
|
||||||
|
import {makeIntlFormatter} from 'react-timeago/defaultFormatter';
|
||||||
|
|
||||||
const PREVIEW_LENGTH = 500;
|
const PREVIEW_LENGTH = 500;
|
||||||
|
|
||||||
const styles = (theme: Theme) =>
|
const useStyles = makeStyles()((theme: Theme) => ({
|
||||||
createStyles({
|
|
||||||
header: {
|
header: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
|
@ -29,21 +29,21 @@ const styles = (theme: Theme) =>
|
||||||
marginRight: -15,
|
marginRight: -15,
|
||||||
},
|
},
|
||||||
wrapperPadding: {
|
wrapperPadding: {
|
||||||
padding: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
messageContentWrapper: {
|
messageContentWrapper: {
|
||||||
|
minWidth: 200,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 585,
|
|
||||||
},
|
},
|
||||||
image: {
|
image: {
|
||||||
marginRight: 15,
|
marginRight: 15,
|
||||||
[theme.breakpoints.down('sm')]: {
|
[theme.breakpoints.down('md')]: {
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
date: {
|
date: {
|
||||||
[theme.breakpoints.down('sm')]: {
|
[theme.breakpoints.down('md')]: {
|
||||||
order: 1,
|
order: 1,
|
||||||
flexBasis: '100%',
|
flexBasis: '100%',
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
|
|
@ -75,7 +75,7 @@ const styles = (theme: Theme) =>
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -83,14 +83,11 @@ interface IProps {
|
||||||
date: string;
|
date: string;
|
||||||
content: string;
|
content: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
|
appName: string;
|
||||||
fDelete: VoidFunction;
|
fDelete: VoidFunction;
|
||||||
extras?: IMessageExtras;
|
extras?: IMessageExtras;
|
||||||
height: (height: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IState {
|
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
isOverflowing: boolean;
|
onExpand: (expand: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const priorityColor = (priority: number) => {
|
const priorityColor = (priority: number) => {
|
||||||
|
|
@ -103,53 +100,44 @@ const priorityColor = (priority: number) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
class Message extends React.PureComponent<IProps & WithStyles<typeof styles>, IState> {
|
const Message = ({
|
||||||
public state = {expanded: false, isOverflowing: false};
|
fDelete,
|
||||||
private node: HTMLDivElement | null = null;
|
title,
|
||||||
private previewRef: RefObject<HTMLDivElement>;
|
date,
|
||||||
|
image,
|
||||||
|
priority,
|
||||||
|
content,
|
||||||
|
extras,
|
||||||
|
appName,
|
||||||
|
onExpand,
|
||||||
|
expanded: initialExpanded,
|
||||||
|
}: IProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [previewRef, setPreviewRef] = React.useState<HTMLDivElement | null>(null);
|
||||||
|
const {classes} = useStyles();
|
||||||
|
const [expanded, setExpanded] = React.useState(initialExpanded);
|
||||||
|
const [isOverflowing, setOverflowing] = React.useState(false);
|
||||||
|
const dateWrapped = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
constructor(props: IProps & WithStyles<typeof styles>) {
|
React.useEffect(() => {
|
||||||
super(props);
|
setOverflowing(!!previewRef && previewRef.scrollHeight > previewRef.clientHeight);
|
||||||
this.previewRef = React.createRef();
|
}, [previewRef]);
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidMount = () => {
|
React.useEffect(() => void onExpand(expanded), [expanded]);
|
||||||
if (this.previewRef.current) {
|
|
||||||
this.setState({
|
|
||||||
isOverflowing:
|
|
||||||
this.previewRef.current.scrollHeight > this.previewRef.current.clientHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.updateHeightInParent();
|
|
||||||
};
|
|
||||||
|
|
||||||
public togglePreviewHeight = () => {
|
const togglePreviewHeight = () => setExpanded((b) => !b);
|
||||||
this.setState(
|
|
||||||
(state) => ({expanded: !state.expanded}),
|
|
||||||
() => this.updateHeightInParent()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
private updateHeightInParent = () =>
|
const renderContent = () => {
|
||||||
this.props.height(this.node ? this.node.getBoundingClientRect().height : 0);
|
switch (contentType(extras)) {
|
||||||
|
|
||||||
private renderContent = () => {
|
|
||||||
const content = this.props.content;
|
|
||||||
|
|
||||||
switch (contentType(this.props.extras)) {
|
|
||||||
case RenderMode.Markdown:
|
case RenderMode.Markdown:
|
||||||
return <Markdown>{content}</Markdown>;
|
return <Markdown>{content}</Markdown>;
|
||||||
case RenderMode.Plain:
|
case RenderMode.Plain:
|
||||||
default:
|
default:
|
||||||
return <span className={this.props.classes.plainContent}>{content}</span>;
|
return <span className={classes.plainContent}>{content}</span>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public render(): React.ReactNode {
|
|
||||||
const {fDelete, classes, title, date, image, priority} = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${classes.wrapperPadding} message`} ref={(ref) => (this.node = ref)}>
|
<div className={`${classes.wrapperPadding} message`}>
|
||||||
<Container
|
<Container
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -158,11 +146,12 @@ class Message extends React.PureComponent<IProps & WithStyles<typeof styles>, IS
|
||||||
borderLeftWidth: 6,
|
borderLeftWidth: 6,
|
||||||
borderLeftStyle: 'solid',
|
borderLeftStyle: 'solid',
|
||||||
}}>
|
}}>
|
||||||
|
<div style={{display: 'flex', width: '100%'}}>
|
||||||
<div className={classes.imageWrapper}>
|
<div className={classes.imageWrapper}>
|
||||||
{image !== null ? (
|
{image !== null ? (
|
||||||
<img
|
<img
|
||||||
src={config.get('url') + image}
|
src={config.get('url') + image}
|
||||||
alt="app logo"
|
alt={`${appName} logo`}
|
||||||
width="70"
|
width="70"
|
||||||
height="70"
|
height="70"
|
||||||
className={classes.image}
|
className={classes.image}
|
||||||
|
|
@ -175,38 +164,46 @@ class Message extends React.PureComponent<IProps & WithStyles<typeof styles>, IS
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" className={classes.date}>
|
<Typography variant="body1" className={classes.date}>
|
||||||
<TimeAgo date={date} />
|
<TimeAgo
|
||||||
|
date={date}
|
||||||
|
formatter={makeIntlFormatter({
|
||||||
|
style: dateWrapped ? 'long' : 'narrow',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton onClick={fDelete} className={`${classes.trash} delete`}>
|
<IconButton
|
||||||
|
onClick={fDelete}
|
||||||
|
className={`${classes.trash} delete`}
|
||||||
|
size="large">
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Typography
|
<Typography
|
||||||
component="div"
|
component="div"
|
||||||
ref={this.previewRef}
|
ref={setPreviewRef}
|
||||||
className={`${classes.content} content ${
|
className={`${classes.content} content ${
|
||||||
this.state.isOverflowing && this.state.expanded ? 'expanded' : ''
|
isOverflowing && expanded ? 'expanded' : ''
|
||||||
}`}>
|
}`}>
|
||||||
{this.renderContent()}
|
{renderContent()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
{this.state.isOverflowing && (
|
</div>
|
||||||
|
{isOverflowing && (
|
||||||
<Button
|
<Button
|
||||||
style={{marginTop: 16}}
|
style={{marginTop: 16}}
|
||||||
onClick={() => this.togglePreviewHeight()}
|
onClick={togglePreviewHeight}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
size="large"
|
size="large"
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
startIcon={this.state.expanded ? <ExpandLess /> : <ExpandMore />}>
|
startIcon={expanded ? <ExpandLess /> : <ExpandMore />}>
|
||||||
{this.state.expanded ? 'Read Less' : 'Read More'}
|
{expanded ? 'Read Less' : 'Read More'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default withStyles(styles, {withTheme: true})(Message);
|
export default Message;
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,93 @@
|
||||||
import Grid from '@material-ui/core/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import React, {Component} from 'react';
|
import React from 'react';
|
||||||
import {RouteComponentProps} from 'react-router';
|
import {useParams} from 'react-router';
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Message from './Message';
|
import Message from './Message';
|
||||||
import {observer} from 'mobx-react';
|
import {observer} from 'mobx-react';
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {observable} from 'mobx';
|
|
||||||
import ReactInfinite from 'react-infinite';
|
|
||||||
import {IMessage} from '../types';
|
import {IMessage} from '../types';
|
||||||
import ConfirmDialog from '../common/ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import LoadingSpinner from '../common/LoadingSpinner';
|
import LoadingSpinner from '../common/LoadingSpinner';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
import {Virtuoso} from 'react-virtuoso';
|
||||||
|
|
||||||
type IProps = RouteComponentProps<{id: string}>;
|
const Messages = observer(() => {
|
||||||
|
const {id} = useParams<{id: string}>();
|
||||||
|
const appId = id == null ? -1 : parseInt(id as string, 10);
|
||||||
|
|
||||||
interface IState {
|
const [deleteAll, setDeleteAll] = React.useState(false);
|
||||||
appId: number;
|
const [isLoadingMore, setLoadingMore] = React.useState(false);
|
||||||
}
|
const {messagesStore, appStore} = useStores();
|
||||||
|
|
||||||
@observer
|
|
||||||
class Messages extends Component<IProps & Stores<'messagesStore' | 'appStore'>, IState> {
|
|
||||||
@observable
|
|
||||||
private heights: Record<string, number> = {};
|
|
||||||
@observable
|
|
||||||
private deleteAll = false;
|
|
||||||
|
|
||||||
private static appId(props: IProps) {
|
|
||||||
if (props === undefined) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
const {match} = props;
|
|
||||||
return match.params.id !== undefined ? parseInt(match.params.id, 10) : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public state = {appId: -1};
|
|
||||||
|
|
||||||
private isLoadingMore = false;
|
|
||||||
|
|
||||||
public componentWillReceiveProps(nextProps: IProps & Stores<'messagesStore' | 'appStore'>) {
|
|
||||||
this.updateAllWithProps(nextProps);
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillMount() {
|
|
||||||
window.onscroll = () => {
|
|
||||||
if (
|
|
||||||
window.innerHeight + window.pageYOffset >=
|
|
||||||
document.body.offsetHeight - window.innerHeight * 2
|
|
||||||
) {
|
|
||||||
this.checkIfLoadMore();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.updateAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {appId} = this.state;
|
|
||||||
const {messagesStore, appStore} = this.props;
|
|
||||||
const messages = messagesStore.get(appId);
|
const messages = messagesStore.get(appId);
|
||||||
const hasMore = messagesStore.canLoadMore(appId);
|
const hasMore = messagesStore.canLoadMore(appId);
|
||||||
const name = appStore.getName(appId);
|
const name = appStore.getName(appId);
|
||||||
const hasMessages = messages.length !== 0;
|
const hasMessages = messages.length !== 0;
|
||||||
|
const expandedState = React.useRef<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
const deleteMessage = (message: IMessage) => () => messagesStore.removeSingle(message);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!messagesStore.loaded(appId)) {
|
||||||
|
messagesStore.loadMore(appId);
|
||||||
|
}
|
||||||
|
}, [appId]);
|
||||||
|
|
||||||
|
const renderMessage = (index: number, message: IMessage) => (
|
||||||
|
<Message
|
||||||
|
key={index}
|
||||||
|
fDelete={deleteMessage(message)}
|
||||||
|
onExpand={(expanded) => (expandedState.current[message.id] = expanded)}
|
||||||
|
title={message.title}
|
||||||
|
date={message.date}
|
||||||
|
appName={appStore.getName(message.appid)}
|
||||||
|
expanded={expandedState.current[message.id] ?? false}
|
||||||
|
content={message.message}
|
||||||
|
image={message.image}
|
||||||
|
extras={message.extras}
|
||||||
|
priority={message.priority}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkIfLoadMore = () => {
|
||||||
|
if (!isLoadingMore && messagesStore.canLoadMore(appId)) {
|
||||||
|
setLoadingMore(true);
|
||||||
|
messagesStore.loadMore(appId).then(() => setLoadingMore(false));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageFooter = () => {
|
||||||
|
if (hasMore) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
if (hasMessages) {
|
||||||
|
return label("You've reached the end");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMessages = () => (
|
||||||
|
<Virtuoso
|
||||||
|
id="messages"
|
||||||
|
style={{width: '100%'}}
|
||||||
|
useWindowScroll
|
||||||
|
totalCount={messages.length}
|
||||||
|
endReached={checkIfLoadMore}
|
||||||
|
data={messages}
|
||||||
|
itemContent={renderMessage}
|
||||||
|
components={{
|
||||||
|
Footer: messageFooter,
|
||||||
|
EmptyPlaceholder: () => label('No messages'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const label = (text: string) => (
|
||||||
|
<Grid size={{xs: 12}}>
|
||||||
|
<Typography variant="caption" component="div" gutterBottom align="center">
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<DefaultPage
|
<DefaultPage
|
||||||
title={name}
|
title={name}
|
||||||
|
|
@ -81,88 +107,24 @@ class Messages extends Component<IProps & Stores<'messagesStore' | 'appStore'>,
|
||||||
disabled={!hasMessages}
|
disabled={!hasMessages}
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
this.deleteAll = true;
|
setDeleteAll(true);
|
||||||
}}>
|
}}>
|
||||||
Delete All
|
Delete All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
{!messagesStore.loaded(appId) ? (
|
{!messagesStore.loaded(appId) ? <LoadingSpinner /> : renderMessages()}
|
||||||
<LoadingSpinner />
|
|
||||||
) : hasMessages ? (
|
|
||||||
<div style={{width: '100%'}} id="messages">
|
|
||||||
<ReactInfinite
|
|
||||||
key={appId}
|
|
||||||
useWindowAsScrollContainer
|
|
||||||
preloadBatchSize={window.innerHeight * 3}
|
|
||||||
elementHeight={messages.map((m) => this.heights[m.id] || 1)}>
|
|
||||||
{messages.map(this.renderMessage)}
|
|
||||||
</ReactInfinite>
|
|
||||||
|
|
||||||
{hasMore ? <LoadingSpinner /> : this.label("You've reached the end")}
|
{deleteAll && (
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
this.label('No messages')
|
|
||||||
)}
|
|
||||||
|
|
||||||
{this.deleteAll && (
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Confirm Delete"
|
title="Confirm Delete"
|
||||||
text={'Delete all messages?'}
|
text={'Delete all messages?'}
|
||||||
fClose={() => (this.deleteAll = false)}
|
fClose={() => setDeleteAll(false)}
|
||||||
fOnSubmit={() => messagesStore.removeByApp(appId)}
|
fOnSubmit={() => messagesStore.removeByApp(appId)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
private updateAllWithProps = (props: IProps & Stores<'messagesStore'>) => {
|
export default Messages;
|
||||||
const appId = Messages.appId(props);
|
|
||||||
this.setState({appId});
|
|
||||||
if (!props.messagesStore.exists(appId)) {
|
|
||||||
props.messagesStore.loadMore(appId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private updateAll = () => this.updateAllWithProps(this.props);
|
|
||||||
|
|
||||||
private deleteMessage = (message: IMessage) => () =>
|
|
||||||
this.props.messagesStore.removeSingle(message);
|
|
||||||
|
|
||||||
private renderMessage = (message: IMessage) => (
|
|
||||||
<Message
|
|
||||||
key={message.id}
|
|
||||||
height={(height: number) => {
|
|
||||||
if (!this.heights[message.id]) {
|
|
||||||
this.heights[message.id] = height;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
fDelete={this.deleteMessage(message)}
|
|
||||||
title={message.title}
|
|
||||||
date={message.date}
|
|
||||||
content={message.message}
|
|
||||||
image={message.image}
|
|
||||||
extras={message.extras}
|
|
||||||
priority={message.priority}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
private checkIfLoadMore() {
|
|
||||||
const {appId} = this.state;
|
|
||||||
if (!this.isLoadingMore && this.props.messagesStore.canLoadMore(appId)) {
|
|
||||||
this.isLoadingMore = true;
|
|
||||||
this.props.messagesStore.loadMore(appId).then(() => (this.isLoadingMore = false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private label = (text: string) => (
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Typography variant="caption" component="div" gutterBottom align="center">
|
|
||||||
{text}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default inject('messagesStore', 'appStore')(Messages);
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import {BaseStore} from '../common/BaseStore';
|
import {BaseStore} from '../common/BaseStore';
|
||||||
import {action, IObservableArray, observable, reaction} from 'mobx';
|
import {action, IObservableArray, observable, reaction, makeObservable} from 'mobx';
|
||||||
import axios, {AxiosResponse} from 'axios';
|
import axios, {AxiosResponse} from 'axios';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import {createTransformer} from 'mobx-utils';
|
import {createTransformer} from 'mobx-utils';
|
||||||
|
|
@ -16,7 +16,6 @@ interface MessagesState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MessagesStore {
|
export class MessagesStore {
|
||||||
@observable
|
|
||||||
private state: Record<string, MessagesState> = {};
|
private state: Record<string, MessagesState> = {};
|
||||||
|
|
||||||
private loading = false;
|
private loading = false;
|
||||||
|
|
@ -25,6 +24,16 @@ export class MessagesStore {
|
||||||
private readonly appStore: BaseStore<IApplication>,
|
private readonly appStore: BaseStore<IApplication>,
|
||||||
private readonly snack: SnackReporter
|
private readonly snack: SnackReporter
|
||||||
) {
|
) {
|
||||||
|
makeObservable<MessagesStore, 'state'>(this, {
|
||||||
|
state: observable,
|
||||||
|
loadMore: action,
|
||||||
|
publishSingleMessage: action,
|
||||||
|
removeByApp: action,
|
||||||
|
removeSingle: action,
|
||||||
|
clearAll: action,
|
||||||
|
refreshByApp: action,
|
||||||
|
});
|
||||||
|
|
||||||
reaction(() => appStore.getItems(), this.createEmptyStatesForApps);
|
reaction(() => appStore.getItems(), this.createEmptyStatesForApps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +48,6 @@ export class MessagesStore {
|
||||||
|
|
||||||
public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore;
|
public canLoadMore = (appId: number) => this.stateOf(appId, /*create*/ false).hasMore;
|
||||||
|
|
||||||
@action
|
|
||||||
public loadMore = async (appId: number) => {
|
public loadMore = async (appId: number) => {
|
||||||
const state = this.stateOf(appId);
|
const state = this.stateOf(appId);
|
||||||
if (!state.hasMore || this.loading) {
|
if (!state.hasMore || this.loading) {
|
||||||
|
|
@ -47,19 +55,21 @@ export class MessagesStore {
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
const pagedResult = await this.fetchMessages(appId, state.nextSince).then(
|
const pagedResult = await this.fetchMessages(appId, state.nextSince).then(
|
||||||
(resp) => resp.data
|
(resp) => resp.data
|
||||||
);
|
);
|
||||||
|
|
||||||
state.messages.replace([...state.messages, ...pagedResult.messages]);
|
state.messages.replace([...state.messages, ...pagedResult.messages]);
|
||||||
state.nextSince = pagedResult.paging.since ?? 0;
|
state.nextSince = pagedResult.paging.since ?? 0;
|
||||||
state.hasMore = 'next' in pagedResult.paging;
|
state.hasMore = 'next' in pagedResult.paging;
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public publishSingleMessage = (message: IMessage) => {
|
public publishSingleMessage = (message: IMessage) => {
|
||||||
if (this.exists(AllMessages)) {
|
if (this.exists(AllMessages)) {
|
||||||
this.stateOf(AllMessages).messages.unshift(message);
|
this.stateOf(AllMessages).messages.unshift(message);
|
||||||
|
|
@ -69,7 +79,6 @@ export class MessagesStore {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public removeByApp = async (appId: number) => {
|
public removeByApp = async (appId: number) => {
|
||||||
if (appId === AllMessages) {
|
if (appId === AllMessages) {
|
||||||
await axios.delete(config.get('url') + 'message');
|
await axios.delete(config.get('url') + 'message');
|
||||||
|
|
@ -84,7 +93,6 @@ export class MessagesStore {
|
||||||
await this.loadMore(appId);
|
await this.loadMore(appId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public removeSingle = async (message: IMessage) => {
|
public removeSingle = async (message: IMessage) => {
|
||||||
await axios.delete(config.get('url') + 'message/' + message.id);
|
await axios.delete(config.get('url') + 'message/' + message.id);
|
||||||
if (this.exists(AllMessages)) {
|
if (this.exists(AllMessages)) {
|
||||||
|
|
@ -96,13 +104,11 @@ export class MessagesStore {
|
||||||
this.snack('Message deleted');
|
this.snack('Message deleted');
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public clearAll = () => {
|
public clearAll = () => {
|
||||||
this.state = {};
|
this.state = {};
|
||||||
this.createEmptyStatesForApps(this.appStore.getItems());
|
this.createEmptyStatesForApps(this.appStore.getItems());
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public refreshByApp = async (appId: number) => {
|
public refreshByApp = async (appId: number) => {
|
||||||
this.clearAll();
|
this.clearAll();
|
||||||
this.loadMore(appId);
|
this.loadMore(appId);
|
||||||
|
|
@ -136,15 +142,17 @@ export class MessagesStore {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private getUnCached = (appId: number): Array<IMessage & {image: string | null}> => {
|
private getUnCached = (appId: number): Array<IMessage> => {
|
||||||
const appToImage = this.appStore
|
const appToImage: Partial<Record<string, string>> = this.appStore
|
||||||
.getItems()
|
.getItems()
|
||||||
.reduce((all, app) => ({...all, [app.id]: app.image}), {});
|
.reduce((all, app) => ({...all, [app.id]: app.image}), {});
|
||||||
|
|
||||||
return this.stateOf(appId, false).messages.map((message: IMessage) => ({
|
return this.stateOf(appId, false).messages.map(
|
||||||
|
(message: IMessage): IMessage => ({
|
||||||
...message,
|
...message,
|
||||||
image: appToImage[message.appid] || null,
|
image: appToImage[message.appid],
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
public get = createTransformer(this.getUnCached);
|
public get = createTransformer(this.getUnCached);
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,7 @@ export enum RenderMode {
|
||||||
|
|
||||||
export const contentType = (extras?: IMessageExtras): RenderMode => {
|
export const contentType = (extras?: IMessageExtras): RenderMode => {
|
||||||
const type = extract(extras, 'client::display', 'contentType');
|
const type = extract(extras, 'client::display', 'contentType');
|
||||||
const valid = Object.keys(RenderMode)
|
const valid = Object.values(RenderMode).includes(type);
|
||||||
.map((k) => RenderMode[k])
|
|
||||||
.some((mode) => mode === type);
|
|
||||||
return valid ? type : RenderMode.Plain;
|
return valid ? type : RenderMode.Plain;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,58 @@
|
||||||
import React, {Component} from 'react';
|
import React from 'react';
|
||||||
import {RouteComponentProps} from 'react-router';
|
import {useParams} from 'react-router';
|
||||||
import {Markdown} from '../common/Markdown';
|
import {Markdown} from '../common/Markdown';
|
||||||
import {UnControlled as CodeMirror} from 'react-codemirror2';
|
import {material} from '@uiw/codemirror-theme-material';
|
||||||
import 'codemirror/lib/codemirror.css';
|
import CodeMirror from '@uiw/react-codemirror';
|
||||||
import 'codemirror/theme/material.css';
|
import Info from '@mui/icons-material/Info';
|
||||||
import 'codemirror/mode/yaml/yaml';
|
import Build from '@mui/icons-material/Build';
|
||||||
import Info from '@material-ui/icons/Info';
|
import Subject from '@mui/icons-material/Subject';
|
||||||
import Build from '@material-ui/icons/Build';
|
import Refresh from '@mui/icons-material/Refresh';
|
||||||
import Subject from '@material-ui/icons/Subject';
|
import Button from '@mui/material/Button';
|
||||||
import Refresh from '@material-ui/icons/Refresh';
|
import Typography from '@mui/material/Typography';
|
||||||
import Button from '@material-ui/core/Button';
|
|
||||||
import Typography from '@material-ui/core/Typography';
|
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import Container from '../common/Container';
|
import Container from '../common/Container';
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {IPlugin} from '../types';
|
import {IPlugin} from '../types';
|
||||||
import LoadingSpinner from '../common/LoadingSpinner';
|
import LoadingSpinner from '../common/LoadingSpinner';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
|
||||||
type IProps = RouteComponentProps<{id: string}>;
|
const PluginDetailView = () => {
|
||||||
|
const {id} = useParams<{id: string}>();
|
||||||
|
const pluginID = parseInt(id as string, 10);
|
||||||
|
const {pluginStore} = useStores();
|
||||||
|
const [currentConfig, setCurrentConfig] = React.useState<string>();
|
||||||
|
const [displayText, setDisplayText] = React.useState<string>();
|
||||||
|
|
||||||
interface IState {
|
const pluginInfo = pluginStore.getByIDOrUndefined(pluginID);
|
||||||
displayText: string | null;
|
|
||||||
currentConfig: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PluginDetailView extends Component<IProps & Stores<'pluginStore'>, IState> {
|
const refreshFeatures = async () => {
|
||||||
private pluginID: number = parseInt(this.props.match.params.id, 10);
|
await pluginStore.refreshIfMissing(pluginID);
|
||||||
private pluginInfo = () => this.props.pluginStore.getByID(this.pluginID);
|
await Promise.all([refreshConfigurer(), refreshDisplayer()]);
|
||||||
|
|
||||||
public state: IState = {
|
|
||||||
displayText: null,
|
|
||||||
currentConfig: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public componentWillMount() {
|
React.useEffect(() => void refreshFeatures(), [pluginID]);
|
||||||
this.refreshFeatures();
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentWillReceiveProps(nextProps: IProps & Stores<'pluginStore'>) {
|
const refreshConfigurer = async () => {
|
||||||
this.pluginID = parseInt(nextProps.match.params.id, 10);
|
if (pluginInfo?.capabilities.indexOf('configurer') !== -1) {
|
||||||
this.refreshFeatures();
|
setCurrentConfig(await pluginStore.requestConfig(pluginID));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private async refreshFeatures() {
|
const refreshDisplayer = async () => {
|
||||||
await this.props.pluginStore.refreshIfMissing(this.pluginID);
|
if (pluginInfo?.capabilities.indexOf('displayer') !== -1) {
|
||||||
return await Promise.all([this.refreshConfigurer(), this.refreshDisplayer()]);
|
setDisplayText(await pluginStore.requestDisplay(pluginID));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private async refreshConfigurer() {
|
if (pluginInfo == null) {
|
||||||
const {
|
|
||||||
props: {pluginStore},
|
|
||||||
} = this;
|
|
||||||
if (this.pluginInfo().capabilities.indexOf('configurer') !== -1) {
|
|
||||||
const response = await pluginStore.requestConfig(this.pluginID);
|
|
||||||
this.setState({currentConfig: response});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshDisplayer() {
|
|
||||||
const {
|
|
||||||
props: {pluginStore},
|
|
||||||
} = this;
|
|
||||||
if (this.pluginInfo().capabilities.indexOf('displayer') !== -1) {
|
|
||||||
const response = await pluginStore.requestDisplay(this.pluginID);
|
|
||||||
this.setState({displayText: response});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const pluginInfo = this.props.pluginStore.getByIDOrUndefined(this.pluginID);
|
|
||||||
if (pluginInfo === undefined) {
|
|
||||||
return <LoadingSpinner />;
|
return <LoadingSpinner />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSaveConfig = async (newConfig: string) => {
|
||||||
|
await pluginStore.changeConfig(pluginID, newConfig);
|
||||||
|
await refreshFeatures();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DefaultPage title={pluginInfo.name} maxWidth={1000}>
|
<DefaultPage title={pluginInfo.name} maxWidth={1000}>
|
||||||
<PanelWrapper name={'Plugin Info'} icon={Info}>
|
<PanelWrapper name={'Plugin Info'} icon={Info}>
|
||||||
|
|
@ -83,18 +63,11 @@ class PluginDetailView extends Component<IProps & Stores<'pluginStore'>, IState>
|
||||||
name={'Configurer'}
|
name={'Configurer'}
|
||||||
description={'This is the configuration panel for this plugin.'}
|
description={'This is the configuration panel for this plugin.'}
|
||||||
icon={Build}
|
icon={Build}
|
||||||
refresh={this.refreshConfigurer.bind(this)}>
|
refresh={refreshConfigurer}>
|
||||||
<ConfigurerPanel
|
<ConfigurerPanel
|
||||||
pluginInfo={pluginInfo}
|
pluginInfo={pluginInfo}
|
||||||
initialConfig={
|
initialConfig={currentConfig != null ? currentConfig : 'Loading...'}
|
||||||
this.state.currentConfig !== null
|
save={handleSaveConfig}
|
||||||
? this.state.currentConfig
|
|
||||||
: 'Loading...'
|
|
||||||
}
|
|
||||||
save={async (newConfig) => {
|
|
||||||
await this.props.pluginStore.changeConfig(this.pluginID, newConfig);
|
|
||||||
await this.refreshFeatures();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
) : null}{' '}
|
) : null}{' '}
|
||||||
|
|
@ -102,22 +75,17 @@ class PluginDetailView extends Component<IProps & Stores<'pluginStore'>, IState>
|
||||||
<PanelWrapper
|
<PanelWrapper
|
||||||
name={'Displayer'}
|
name={'Displayer'}
|
||||||
description={'This is the information generated by the plugin.'}
|
description={'This is the information generated by the plugin.'}
|
||||||
refresh={this.refreshDisplayer.bind(this)}
|
refresh={refreshDisplayer}
|
||||||
icon={Subject}>
|
icon={Subject}>
|
||||||
<DisplayerPanel
|
<DisplayerPanel
|
||||||
pluginInfo={pluginInfo}
|
pluginInfo={pluginInfo}
|
||||||
displayText={
|
displayText={displayText != null ? displayText : 'Loading...'}
|
||||||
this.state.displayText !== null
|
|
||||||
? this.state.displayText
|
|
||||||
: 'Loading...'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
) : null}
|
) : null}
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface IPanelWrapperProps {
|
interface IPanelWrapperProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -126,7 +94,7 @@ interface IPanelWrapperProps {
|
||||||
icon?: React.ComponentType;
|
icon?: React.ComponentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PanelWrapper: React.FC<IPanelWrapperProps> = ({
|
const PanelWrapper: React.FC<React.PropsWithChildren<IPanelWrapperProps>> = ({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
refresh,
|
refresh,
|
||||||
|
|
@ -178,50 +146,39 @@ interface IConfigurerPanelProps {
|
||||||
initialConfig: string;
|
initialConfig: string;
|
||||||
save: (newConfig: string) => Promise<void>;
|
save: (newConfig: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
class ConfigurerPanel extends Component<IConfigurerPanelProps, {unsavedChanges: string | null}> {
|
const ConfigurerPanel = ({initialConfig, save}: IConfigurerPanelProps) => {
|
||||||
public state = {unsavedChanges: null};
|
const [unsavedChanges, setUnsavedChanges] = React.useState<string | null>(null);
|
||||||
|
const onChange = React.useCallback(
|
||||||
public render() {
|
(value: string | null) => {
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<CodeMirror
|
|
||||||
value={this.props.initialConfig}
|
|
||||||
options={{
|
|
||||||
mode: 'yaml',
|
|
||||||
theme: 'material',
|
|
||||||
lineNumbers: true,
|
|
||||||
}}
|
|
||||||
onChange={(_, _1, value) => {
|
|
||||||
let newConf: string | null = value;
|
let newConf: string | null = value;
|
||||||
if (value === this.props.initialConfig) {
|
if (value === initialConfig) {
|
||||||
newConf = null;
|
newConf = null;
|
||||||
}
|
}
|
||||||
this.setState({unsavedChanges: newConf});
|
setUnsavedChanges(newConf);
|
||||||
}}
|
},
|
||||||
/>
|
[initialConfig]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CodeMirror value={initialConfig} theme={material} onChange={onChange} />
|
||||||
<br />
|
<br />
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
fullWidth={true}
|
fullWidth={true}
|
||||||
disabled={
|
disabled={unsavedChanges === null || unsavedChanges === initialConfig}
|
||||||
this.state.unsavedChanges === null ||
|
|
||||||
this.state.unsavedChanges === this.props.initialConfig
|
|
||||||
}
|
|
||||||
className="config-save"
|
className="config-save"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newConfig = this.state.unsavedChanges;
|
const newConfig = unsavedChanges;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
save(newConfig!).then(() => {
|
||||||
this.props.save(newConfig!).then(() => {
|
setUnsavedChanges(null);
|
||||||
this.setState({unsavedChanges: null});
|
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
<Typography variant="button">Save</Typography>
|
<Typography variant="button">Save</Typography>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface IDisplayerPanelProps {
|
interface IDisplayerPanelProps {
|
||||||
pluginInfo: IPlugin;
|
pluginInfo: IPlugin;
|
||||||
|
|
@ -233,13 +190,13 @@ const DisplayerPanel: React.FC<IDisplayerPanelProps> = ({displayText}) => (
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
|
|
||||||
class PluginInfo extends Component<{pluginInfo: IPlugin}> {
|
interface IPluginInfo {
|
||||||
public render() {
|
pluginInfo: IPlugin;
|
||||||
const {
|
}
|
||||||
props: {
|
|
||||||
pluginInfo: {name, author, modulePath, website, license, capabilities, id, token},
|
const PluginInfo = ({pluginInfo}: IPluginInfo) => {
|
||||||
},
|
const {name, author, modulePath, website, license, capabilities, id, token} = pluginInfo;
|
||||||
} = this;
|
|
||||||
return (
|
return (
|
||||||
<div style={{wordWrap: 'break-word'}}>
|
<div style={{wordWrap: 'break-word'}}>
|
||||||
{name ? (
|
{name ? (
|
||||||
|
|
@ -284,7 +241,6 @@ class PluginInfo extends Component<{pluginInfo: IPlugin}> {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default inject('pluginStore')(PluginDetailView);
|
export default PluginDetailView;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import {action} from 'mobx';
|
import {action, makeObservable} from 'mobx';
|
||||||
import {BaseStore} from '../common/BaseStore';
|
import {BaseStore} from '../common/BaseStore';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import {SnackReporter} from '../snack/SnackManager';
|
import {SnackReporter} from '../snack/SnackManager';
|
||||||
|
|
@ -10,6 +10,11 @@ export class PluginStore extends BaseStore<IPlugin> {
|
||||||
|
|
||||||
public constructor(private readonly snack: SnackReporter) {
|
public constructor(private readonly snack: SnackReporter) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
changeConfig: action,
|
||||||
|
changeEnabledState: action,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public requestConfig = (id: number): Promise<string> =>
|
public requestConfig = (id: number): Promise<string> =>
|
||||||
|
|
@ -31,7 +36,6 @@ export class PluginStore extends BaseStore<IPlugin> {
|
||||||
return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown';
|
return id === -1 ? 'All Plugins' : plugin !== undefined ? plugin.name : 'unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public changeConfig = async (id: number, newConfig: string): Promise<void> => {
|
public changeConfig = async (id: number, newConfig: string): Promise<void> => {
|
||||||
await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, {
|
await axios.post(`${config.get('url')}plugin/${id}/config`, newConfig, {
|
||||||
headers: {'content-type': 'application/x-yaml'},
|
headers: {'content-type': 'application/x-yaml'},
|
||||||
|
|
@ -40,7 +44,6 @@ export class PluginStore extends BaseStore<IPlugin> {
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public changeEnabledState = async (id: number, enabled: boolean): Promise<void> => {
|
public changeEnabledState = async (id: number, enabled: boolean): Promise<void> => {
|
||||||
await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`);
|
await axios.post(`${config.get('url')}plugin/${id}/${enabled ? 'enable' : 'disable'}`);
|
||||||
this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`);
|
this.snack(`Plugin ${enabled ? 'enabled' : 'disabled'}`);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
import React, {Component, SFC} from 'react';
|
import React from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import Grid from '@material-ui/core/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Paper from '@material-ui/core/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Table from '@material-ui/core/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@material-ui/core/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableCell from '@material-ui/core/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableHead from '@material-ui/core/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@material-ui/core/TableRow';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import Settings from '@material-ui/icons/Settings';
|
import Settings from '@mui/icons-material/Settings';
|
||||||
import {Switch, Button} from '@material-ui/core';
|
import {Switch, Button} from '@mui/material';
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import CopyableSecret from '../common/CopyableSecret';
|
import CopyableSecret from '../common/CopyableSecret';
|
||||||
import {observer} from 'mobx-react';
|
import {observer} from 'mobx-react';
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {IPlugin} from '../types';
|
import {IPlugin} from '../types';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
|
||||||
@observer
|
const Plugins = observer(() => {
|
||||||
class Plugins extends Component<Stores<'pluginStore'>> {
|
const {pluginStore} = useStores();
|
||||||
public componentDidMount = () => this.props.pluginStore.refresh();
|
React.useEffect(() => void pluginStore.refresh(), []);
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {
|
|
||||||
props: {pluginStore},
|
|
||||||
} = this;
|
|
||||||
const plugins = pluginStore.getItems();
|
const plugins = pluginStore.getItems();
|
||||||
return (
|
return (
|
||||||
<DefaultPage title="Plugins" maxWidth={1000}>
|
<DefaultPage title="Plugins" maxWidth={1000}>
|
||||||
<Grid item xs={12}>
|
<Grid size={{xs: 12}}>
|
||||||
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
||||||
<Table id="plugin-table">
|
<Table id="plugin-table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|
@ -47,10 +42,7 @@ class Plugins extends Component<Stores<'pluginStore'>> {
|
||||||
name={plugin.name}
|
name={plugin.name}
|
||||||
enabled={plugin.enabled}
|
enabled={plugin.enabled}
|
||||||
fToggleStatus={() =>
|
fToggleStatus={() =>
|
||||||
this.props.pluginStore.changeEnabledState(
|
pluginStore.changeEnabledState(plugin.id, !plugin.enabled)
|
||||||
plugin.id,
|
|
||||||
!plugin.enabled
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -60,8 +52,7 @@ class Plugins extends Component<Stores<'pluginStore'>> {
|
||||||
</Grid>
|
</Grid>
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
interface IRowProps {
|
interface IRowProps {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -71,7 +62,7 @@ interface IRowProps {
|
||||||
fToggleStatus: VoidFunction;
|
fToggleStatus: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Row: SFC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus}) => (
|
const Row: React.FC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus}) => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{id}</TableCell>
|
<TableCell>{id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -96,4 +87,4 @@ const Row: SFC<IRowProps> = observer(({name, id, token, enabled, fToggleStatus})
|
||||||
</TableRow>
|
</TableRow>
|
||||||
));
|
));
|
||||||
|
|
||||||
export default inject('pluginStore')(Plugins);
|
export default Plugins;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {StoreMapping} from './inject';
|
|
||||||
import {reaction} from 'mobx';
|
import {reaction} from 'mobx';
|
||||||
import * as Notifications from './snack/browserNotification';
|
import * as Notifications from './snack/browserNotification';
|
||||||
|
import {StoreMapping} from './stores';
|
||||||
|
|
||||||
export const registerReactions = (stores: StoreMapping) => {
|
export const registerReactions = (stores: StoreMapping) => {
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
|
|
@ -40,6 +40,7 @@ export const registerReactions = (stores: StoreMapping) => {
|
||||||
if (!connectionErrorMessage) {
|
if (!connectionErrorMessage) {
|
||||||
clearAll();
|
clearAll();
|
||||||
loadAll();
|
loadAll();
|
||||||
|
stores.currentUser.refreshKey++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
jest.setTimeout(process.env.CI === 'true' ? 50000 : 20000);
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
|
||||||
import Snackbar from '@material-ui/core/Snackbar';
|
|
||||||
import Close from '@material-ui/icons/Close';
|
|
||||||
import React, {Component} from 'react';
|
|
||||||
import {observable, reaction} from 'mobx';
|
|
||||||
import {observer} from 'mobx-react';
|
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
|
|
||||||
@observer
|
|
||||||
class SnackBarHandler extends Component<Stores<'snackManager'>> {
|
|
||||||
private static MAX_VISIBLE_SNACK_TIME_IN_MS = 6000;
|
|
||||||
private static MIN_VISIBLE_SNACK_TIME_IN_MS = 1000;
|
|
||||||
|
|
||||||
@observable
|
|
||||||
private open = false;
|
|
||||||
@observable
|
|
||||||
private openWhen = 0;
|
|
||||||
|
|
||||||
private dispose: () => void = () => {};
|
|
||||||
|
|
||||||
public componentDidMount = () =>
|
|
||||||
(this.dispose = reaction(() => this.props.snackManager.counter, this.onNewSnack));
|
|
||||||
|
|
||||||
public componentWillUnmount = () => this.dispose();
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {message: current, hasNext} = this.props.snackManager;
|
|
||||||
const duration = hasNext()
|
|
||||||
? SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS
|
|
||||||
: SnackBarHandler.MAX_VISIBLE_SNACK_TIME_IN_MS;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Snackbar
|
|
||||||
anchorOrigin={{vertical: 'bottom', horizontal: 'left'}}
|
|
||||||
open={this.open}
|
|
||||||
autoHideDuration={duration}
|
|
||||||
onClose={this.closeCurrentSnack}
|
|
||||||
onExited={this.openNextSnack}
|
|
||||||
message={<span id="message-id">{current}</span>}
|
|
||||||
action={
|
|
||||||
<IconButton
|
|
||||||
key="close"
|
|
||||||
aria-label="Close"
|
|
||||||
color="inherit"
|
|
||||||
onClick={this.closeCurrentSnack}>
|
|
||||||
<Close />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onNewSnack = () => {
|
|
||||||
const {open, openWhen} = this;
|
|
||||||
|
|
||||||
if (!open) {
|
|
||||||
this.openNextSnack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snackOpenSince = Date.now() - openWhen;
|
|
||||||
if (snackOpenSince > SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS) {
|
|
||||||
this.closeCurrentSnack();
|
|
||||||
} else {
|
|
||||||
setTimeout(
|
|
||||||
this.closeCurrentSnack,
|
|
||||||
SnackBarHandler.MIN_VISIBLE_SNACK_TIME_IN_MS - snackOpenSince
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private openNextSnack = () => {
|
|
||||||
if (this.props.snackManager.hasNext()) {
|
|
||||||
this.open = true;
|
|
||||||
this.openWhen = Date.now();
|
|
||||||
this.props.snackManager.next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private closeCurrentSnack = () => (this.open = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default inject('snackManager')(SnackBarHandler);
|
|
||||||
|
|
@ -1,30 +1,11 @@
|
||||||
import {action, observable} from 'mobx';
|
import {enqueueSnackbar} from 'notistack';
|
||||||
|
|
||||||
export interface SnackReporter {
|
export interface SnackReporter {
|
||||||
(message: string): void;
|
(message: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SnackManager {
|
export class SnackManager {
|
||||||
@observable
|
|
||||||
private messages: string[] = [];
|
|
||||||
@observable
|
|
||||||
public message: string | null = null;
|
|
||||||
@observable
|
|
||||||
public counter = 0;
|
|
||||||
|
|
||||||
@action
|
|
||||||
public next = (): void => {
|
|
||||||
if (!this.hasNext()) {
|
|
||||||
throw new Error('There is nothing here :(');
|
|
||||||
}
|
|
||||||
this.message = this.messages.shift() as string;
|
|
||||||
};
|
|
||||||
|
|
||||||
public hasNext = () => this.messages.length > 0;
|
|
||||||
|
|
||||||
@action
|
|
||||||
public snack: SnackReporter = (message: string): void => {
|
public snack: SnackReporter = (message: string): void => {
|
||||||
this.messages.push(message);
|
enqueueSnackbar({message, variant: 'info'});
|
||||||
this.counter++;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {MessagesStore} from './message/MessagesStore';
|
||||||
import {CurrentUser} from './CurrentUser';
|
import {CurrentUser} from './CurrentUser';
|
||||||
import {ClientStore} from './client/ClientStore';
|
import {ClientStore} from './client/ClientStore';
|
||||||
import {AppStore} from './application/AppStore';
|
import {AppStore} from './application/AppStore';
|
||||||
import {inject as mobxInject, Provider} from 'mobx-react';
|
|
||||||
import {WebSocketStore} from './message/WebSocketStore';
|
import {WebSocketStore} from './message/WebSocketStore';
|
||||||
import {PluginStore} from './plugin/PluginStore';
|
import {PluginStore} from './plugin/PluginStore';
|
||||||
|
|
||||||
|
|
@ -20,18 +19,10 @@ export interface StoreMapping {
|
||||||
wsStore: WebSocketStore;
|
wsStore: WebSocketStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllStores = Extract<keyof StoreMapping, string>;
|
export const StoreContext = React.createContext<StoreMapping | undefined>(undefined);
|
||||||
export type Stores<T extends AllStores> = Pick<StoreMapping, T>;
|
|
||||||
|
|
||||||
export const inject =
|
export const useStores = (): StoreMapping => {
|
||||||
<I extends AllStores>(...stores: I[]) =>
|
const mapping = React.useContext(StoreContext);
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
if (!mapping) throw new Error('uninitialized');
|
||||||
<P extends {}>(
|
return mapping;
|
||||||
node: React.ComponentType<P>
|
};
|
||||||
): React.ComponentType<Pick<P, Exclude<keyof P, I>>> =>
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
mobxInject(...stores)(node) as any;
|
|
||||||
|
|
||||||
export const InjectProvider: React.FC<{stores: StoreMapping}> = ({children, stores}) => (
|
|
||||||
<Provider {...stores}>{children}</Provider>
|
|
||||||
);
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {Page} from 'puppeteer';
|
import {Page} from 'puppeteer';
|
||||||
import {newTest, GotifyTest} from './setup';
|
import {newTest, GotifyTest} from './setup';
|
||||||
import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
|
import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
|
||||||
|
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||||
import * as auth from './authentication';
|
import * as auth from './authentication';
|
||||||
import * as selector from './selector';
|
import * as selector from './selector';
|
||||||
|
|
||||||
|
|
@ -63,6 +64,7 @@ const createApp =
|
||||||
await page.type($dialog.input('.name'), name);
|
await page.type($dialog.input('.name'), name);
|
||||||
await page.type($dialog.textarea('.description'), description);
|
await page.type($dialog.textarea('.description'), description);
|
||||||
await page.click($dialog.button('.create'));
|
await page.click($dialog.button('.create'));
|
||||||
|
await waitToDisappear(page, $dialog.selector());
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Application', () => {
|
describe('Application', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {Page} from 'puppeteer';
|
import {Page} from 'puppeteer';
|
||||||
import {waitForExists} from './utils';
|
import {waitForExists} from './utils';
|
||||||
|
import {expect} from 'vitest';
|
||||||
import * as selector from './selector';
|
import * as selector from './selector';
|
||||||
|
|
||||||
const $loginForm = selector.form('#login-form');
|
const $loginForm = selector.form('#login-form');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {Page} from 'puppeteer';
|
import {Page} from 'puppeteer';
|
||||||
import {newTest, GotifyTest} from './setup';
|
import {newTest, GotifyTest} from './setup';
|
||||||
import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
|
import {count, innerText, waitForExists, waitToDisappear, clearField} from './utils';
|
||||||
|
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||||
import * as auth from './authentication';
|
import * as auth from './authentication';
|
||||||
|
|
||||||
import * as selector from './selector';
|
import * as selector from './selector';
|
||||||
|
|
@ -65,6 +66,7 @@ describe('Client', () => {
|
||||||
await page.waitForSelector($dialog.selector());
|
await page.waitForSelector($dialog.selector());
|
||||||
await page.type($dialog.input('.name'), name);
|
await page.type($dialog.input('.name'), name);
|
||||||
await page.click($dialog.button('.create'));
|
await page.click($dialog.button('.create'));
|
||||||
|
await waitToDisappear(page, $dialog.selector());
|
||||||
};
|
};
|
||||||
it('phone', createClient('phone'));
|
it('phone', createClient('phone'));
|
||||||
it('desktop app', createClient('desktop app'));
|
it('desktop app', createClient('desktop app'));
|
||||||
|
|
@ -98,7 +100,6 @@ describe('Client', () => {
|
||||||
|
|
||||||
expect(await count(page, $table.rows())).toBe(2);
|
expect(await count(page, $table.rows())).toBe(2);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line
|
|
||||||
it('deletes own client', async () => {
|
it('deletes own client', async () => {
|
||||||
await page.click($table.cell(1, Col.Delete, '.delete'));
|
await page.click($table.cell(1, Col.Delete, '.delete'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import {Page} from 'puppeteer';
|
import {Page} from 'puppeteer';
|
||||||
import {newTest, GotifyTest} from './setup';
|
import {newTest, GotifyTest} from './setup';
|
||||||
import {clickByText, count, innerText, waitForCount, waitForExists} from './utils';
|
import {clickByText, count, innerText, waitForCount, waitForExists} from './utils';
|
||||||
|
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||||
import * as auth from './authentication';
|
import * as auth from './authentication';
|
||||||
import * as selector from './selector';
|
import * as selector from './selector';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
@ -16,7 +17,6 @@ beforeAll(async () => {
|
||||||
|
|
||||||
afterAll(async () => await gotify.close());
|
afterAll(async () => await gotify.close());
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
const axiosAuth = {auth: {username: 'admin', password: 'admin'}};
|
const axiosAuth = {auth: {username: 'admin', password: 'admin'}};
|
||||||
|
|
||||||
let windowsServerToken: string;
|
let windowsServerToken: string;
|
||||||
|
|
@ -35,7 +35,6 @@ const navigate = async (appName: string) => {
|
||||||
await waitForExists(page, selector.heading(), appName);
|
await waitForExists(page, selector.heading(), appName);
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
describe('Messages', () => {
|
describe('Messages', () => {
|
||||||
it('does login', async () => await auth.login(page));
|
it('does login', async () => await auth.login(page));
|
||||||
it('is on messages', async () => {
|
it('is on messages', async () => {
|
||||||
|
|
@ -83,11 +82,11 @@ describe('Messages', () => {
|
||||||
await navigate('All Messages');
|
await navigate('All Messages');
|
||||||
});
|
});
|
||||||
it('has no messages', async () => {
|
it('has no messages', async () => {
|
||||||
expect(await count(page, '#messages')).toBe(0);
|
expect(await count(page, '#messages .message')).toBe(0);
|
||||||
});
|
});
|
||||||
it('has no messages in app', async () => {
|
it('has no messages in app', async () => {
|
||||||
await navigate('Windows');
|
await navigate('Windows');
|
||||||
expect(await count(page, '#messages')).toBe(0);
|
expect(await count(page, '#messages .message')).toBe(0);
|
||||||
await navigate('All Messages');
|
await navigate('All Messages');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import {Page} from 'puppeteer';
|
import {Page} from 'puppeteer';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||||
import * as auth from './authentication';
|
import * as auth from './authentication';
|
||||||
import * as selector from './selector';
|
import * as selector from './selector';
|
||||||
import {GotifyTest, newTest, newPluginDir} from './setup';
|
import {GotifyTest, newTest, newPluginDir} from './setup';
|
||||||
|
|
@ -57,6 +57,7 @@ const getDisplayer = async () => await innerText(page, '.displayer');
|
||||||
const hasReceivedMessage = async (title: RegExp, content: RegExp) => {
|
const hasReceivedMessage = async (title: RegExp, content: RegExp) => {
|
||||||
await page.click('#message-navigation a');
|
await page.click('#message-navigation a');
|
||||||
await waitForExists(page, selector.heading(), 'All Messages');
|
await waitForExists(page, selector.heading(), 'All Messages');
|
||||||
|
await waitForCount(page, '#messages .message', 1);
|
||||||
|
|
||||||
expect(await innerText(page, '.title')).toMatch(title);
|
expect(await innerText(page, '.title')).toMatch(title);
|
||||||
expect(await innerText(page, '.content')).toMatch(content);
|
expect(await innerText(page, '.content')).toMatch(content);
|
||||||
|
|
@ -136,11 +137,11 @@ describe('plugin', () => {
|
||||||
await (await page.$('.config-save'))!.getProperty('disabled')
|
await (await page.$('.config-save'))!.getProperty('disabled')
|
||||||
).jsonValue()
|
).jsonValue()
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await page.waitForSelector('.CodeMirror .CodeMirror-code');
|
await page.waitForSelector('.cm-editor .cm-content');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
'document.querySelector(".CodeMirror .CodeMirror-code").innerText.toLowerCase().indexOf("loading")<0'
|
'document.querySelector(".cm-editor .cm-content").innerText.toLowerCase().indexOf("loading")<0'
|
||||||
);
|
);
|
||||||
await page.click('.CodeMirror .CodeMirror-code > div');
|
await page.click('.cm-editor .cm-content > div');
|
||||||
await page.keyboard.press('x');
|
await page.keyboard.press('x');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
'document.querySelector(".config-save") && !document.querySelector(".config-save").disabled'
|
'document.querySelector(".config-save") && !document.querySelector(".config-save").disabled'
|
||||||
|
|
@ -156,13 +157,11 @@ describe('plugin', () => {
|
||||||
await (await page.$('.config-save'))!.getProperty('disabled')
|
await (await page.$('.config-save'))!.getProperty('disabled')
|
||||||
).jsonValue()
|
).jsonValue()
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await page.waitForSelector('.CodeMirror .CodeMirror-code > div');
|
await page.waitForSelector('.cm-editor .cm-content > div');
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
'document.querySelector(".CodeMirror .CodeMirror-code > div").innerText.toLowerCase().indexOf("loading")<0'
|
'document.querySelector(".cm-editor .cm-content > div").innerText.toLowerCase().indexOf("loading")<0'
|
||||||
);
|
|
||||||
expect(await innerText(page, '.CodeMirror .CodeMirror-code > div')).toMatch(
|
|
||||||
/x$/
|
|
||||||
);
|
);
|
||||||
|
expect(await innerText(page, '.cm-editor .cm-content > div')).toMatch(/x$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('sends messages', async () => {
|
it('sends messages', async () => {
|
||||||
|
|
@ -172,6 +171,9 @@ describe('plugin', () => {
|
||||||
await inDetailPage(1, async () => {
|
await inDetailPage(1, async () => {
|
||||||
await page.waitForSelector('.displayer a');
|
await page.waitForSelector('.displayer a');
|
||||||
const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href'));
|
const hook = await page.$eval('.displayer a', (el) => el.getAttribute('href'));
|
||||||
|
if (!hook) {
|
||||||
|
throw 'href not found';
|
||||||
|
}
|
||||||
await axios.get(hook);
|
await axios.get(hook);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import getPort from 'get-port';
|
import getPort from 'get-port';
|
||||||
import {spawn, exec, ChildProcess} from 'child_process';
|
import {spawn, exec, ChildProcess} from 'child_process';
|
||||||
import rimraf from 'rimraf';
|
import {rimrafSync} from 'rimraf';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import puppeteer, {Browser, Page} from 'puppeteer';
|
import puppeteer, {Browser, Page} from 'puppeteer';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
// @ts-ignore
|
// @ts-expect-error no types
|
||||||
import wait from 'wait-on';
|
import wait from 'wait-on';
|
||||||
import kill from 'tree-kill';
|
import kill from 'tree-kill';
|
||||||
|
|
||||||
|
|
@ -50,9 +50,11 @@ export const newTest = async (pluginsDir = ''): Promise<GotifyTest> => {
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
browser.close(),
|
browser.close(),
|
||||||
new Promise((resolve) => kill(gotifyInstance.pid!, 'SIGKILL', () => resolve())),
|
new Promise((resolve) =>
|
||||||
|
kill(gotifyInstance.pid!, 'SIGKILL', () => resolve(undefined))
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
rimraf.sync(gotifyFile, {maxBusyTries: 8});
|
rimrafSync(gotifyFile, {maxRetries: 8});
|
||||||
},
|
},
|
||||||
url: gotifyURL,
|
url: gotifyURL,
|
||||||
browser,
|
browser,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {Page} from 'puppeteer';
|
import {Page} from 'puppeteer';
|
||||||
import {newTest, GotifyTest} from './setup';
|
import {newTest, GotifyTest} from './setup';
|
||||||
import {clearField, count, innerText, waitForExists, waitToDisappear} from './utils';
|
import {clearField, count, innerText, waitForExists, waitToDisappear} from './utils';
|
||||||
|
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||||
import * as auth from './authentication';
|
import * as auth from './authentication';
|
||||||
import * as selector from './selector';
|
import * as selector from './selector';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const waitForExists = async (page: Page, selector: string, text: string):
|
||||||
export const clearField = async (element: ElementHandle | Page, selector: string) => {
|
export const clearField = async (element: ElementHandle | Page, selector: string) => {
|
||||||
const elementHandle = await element.$(selector);
|
const elementHandle = await element.$(selector);
|
||||||
if (!elementHandle) {
|
if (!elementHandle) {
|
||||||
fail();
|
throw 'element handle not set';
|
||||||
}
|
}
|
||||||
await elementHandle.click();
|
await elementHandle.click();
|
||||||
await elementHandle.focus();
|
await elementHandle.focus();
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// eslint-disable-next-line
|
||||||
import Notify = require('notifyjs');
|
import Notify = require('notifyjs');
|
||||||
export as namespace notifyjs;
|
export as namespace notifyjs;
|
||||||
export = Notify;
|
export = Notify;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,19 @@
|
||||||
declare module 'react-timeago' {
|
declare module 'react-timeago' {
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
export type FormatterOptions = {
|
||||||
|
style?: 'long' | 'short' | 'narrow';
|
||||||
|
};
|
||||||
|
export type Formatter = (options: FormatterOptions) => React.ReactNode;
|
||||||
|
|
||||||
export interface ITimeAgoProps {
|
export interface ITimeAgoProps {
|
||||||
date: string;
|
date: string;
|
||||||
|
formatter?: Formatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class TimeAgo extends React.Component<ITimeAgoProps, unknown> {}
|
export default class TimeAgo extends React.Component<ITimeAgoProps, unknown> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module 'react-timeago/defaultFormatter' {
|
||||||
|
declare function makeIntlFormatter(options: FormatterOptions): Formatter;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,37 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import FormControlLabel from '@material-ui/core/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
import Switch from '@material-ui/core/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import React, {ChangeEvent, Component} from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
fClose: VoidFunction;
|
fClose: VoidFunction;
|
||||||
fOnSubmit: (name: string, pass: string, admin: boolean) => void;
|
fOnSubmit: (name: string, pass: string, admin: boolean) => Promise<void>;
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
const AddEditUserDialog = ({
|
||||||
name: string;
|
fClose,
|
||||||
pass: string;
|
fOnSubmit,
|
||||||
admin: boolean;
|
isEdit,
|
||||||
}
|
name: initialName = '',
|
||||||
|
admin: initialAdmin = false,
|
||||||
|
}: IProps) => {
|
||||||
|
const [name, setName] = React.useState(initialName);
|
||||||
|
const [pass, setPass] = React.useState('');
|
||||||
|
const [admin, setAdmin] = React.useState(initialAdmin);
|
||||||
|
|
||||||
export default class AddEditDialog extends Component<IProps, IState> {
|
const namePresent = name.length !== 0;
|
||||||
public state = {
|
const passPresent = pass.length !== 0 || isEdit;
|
||||||
name: this.props.name ?? '',
|
const submitAndClose = async () => {
|
||||||
pass: '',
|
await fOnSubmit(name, pass, admin);
|
||||||
admin: this.props.admin ?? false,
|
|
||||||
};
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {fClose, fOnSubmit, isEdit} = this.props;
|
|
||||||
const {name, pass, admin} = this.state;
|
|
||||||
const namePresent = this.state.name.length !== 0;
|
|
||||||
const passPresent = this.state.pass.length !== 0 || isEdit;
|
|
||||||
const submitAndClose = () => {
|
|
||||||
fOnSubmit(name, pass, admin);
|
|
||||||
fClose();
|
fClose();
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
|
|
@ -46,7 +41,7 @@ export default class AddEditDialog extends Component<IProps, IState> {
|
||||||
aria-labelledby="form-dialog-title"
|
aria-labelledby="form-dialog-title"
|
||||||
id="add-edit-user-dialog">
|
id="add-edit-user-dialog">
|
||||||
<DialogTitle id="form-dialog-title">
|
<DialogTitle id="form-dialog-title">
|
||||||
{isEdit ? 'Edit ' + this.props.name : 'Add a user'}
|
{isEdit ? 'Edit ' + name : 'Add a user'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -57,7 +52,7 @@ export default class AddEditDialog extends Component<IProps, IState> {
|
||||||
value={name}
|
value={name}
|
||||||
name="username"
|
name="username"
|
||||||
id="username"
|
id="username"
|
||||||
onChange={this.handleChange.bind(this, 'name')}
|
onChange={(e) => setName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -69,14 +64,14 @@ export default class AddEditDialog extends Component<IProps, IState> {
|
||||||
label={isEdit ? 'Password (empty if no change)' : 'Password *'}
|
label={isEdit ? 'Password (empty if no change)' : 'Password *'}
|
||||||
name="password"
|
name="password"
|
||||||
id="password"
|
id="password"
|
||||||
onChange={this.handleChange.bind(this, 'pass')}
|
onChange={(e) => setPass(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={admin}
|
checked={admin}
|
||||||
className="admin-rights"
|
className="admin-rights"
|
||||||
onChange={this.handleChecked.bind(this, 'admin')}
|
onChange={(e) => setAdmin(e.target.checked)}
|
||||||
value="admin"
|
value="admin"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
@ -108,17 +103,5 @@ export default class AddEditDialog extends Component<IProps, IState> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
export default AddEditUserDialog;
|
||||||
private handleChange(propertyName: string, event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const state = this.state;
|
|
||||||
state[propertyName] = event.target.value;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleChecked(propertyName: string, event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const state = this.state;
|
|
||||||
state[propertyName] = event.target.checked;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,48 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Grid from '@material-ui/core/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import React, {Component, FormEvent} from 'react';
|
import React from 'react';
|
||||||
import Container from '../common/Container';
|
import Container from '../common/Container';
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import {observable} from 'mobx';
|
|
||||||
import {observer} from 'mobx-react';
|
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import RegistrationDialog from './Register';
|
import RegistrationDialog from './Register';
|
||||||
|
import {useStores} from '../stores';
|
||||||
|
import {observer} from 'mobx-react';
|
||||||
|
import {useNavigate} from 'react-router';
|
||||||
|
|
||||||
@observer
|
const Login = observer(() => {
|
||||||
class Login extends Component<Stores<'currentUser'>> {
|
const [username, setUsername] = React.useState('');
|
||||||
@observable
|
const [password, setPassword] = React.useState('');
|
||||||
private username = '';
|
const [registerDialog, setRegisterDialog] = React.useState(false);
|
||||||
@observable
|
const {currentUser} = useStores();
|
||||||
private password = '';
|
const navigate = useNavigate();
|
||||||
@observable
|
React.useEffect(() => {
|
||||||
private registerDialog = false;
|
if (currentUser.loggedIn) {
|
||||||
|
navigate('/');
|
||||||
public render() {
|
}
|
||||||
const {username, password, registerDialog} = this;
|
}, [currentUser.loggedIn]);
|
||||||
|
const registerButton = () => {
|
||||||
|
if (config.get('register'))
|
||||||
return (
|
return (
|
||||||
<DefaultPage title="Login" rightControl={this.registerButton()} maxWidth={250}>
|
<Button
|
||||||
<Grid item xs={12} style={{textAlign: 'center'}}>
|
id="register"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setRegisterDialog(true)}>
|
||||||
|
Register
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
else return null;
|
||||||
|
};
|
||||||
|
const login = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
currentUser.login(username, password);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<DefaultPage title="Login" rightControl={registerButton()} maxWidth={250}>
|
||||||
|
<Grid size={{xs: 12}} style={{textAlign: 'center'}}>
|
||||||
<Container>
|
<Container>
|
||||||
<form onSubmit={this.preventDefault} id="login-form">
|
<form onSubmit={(e) => e.preventDefault()} id="login-form">
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
id="username"
|
id="username"
|
||||||
|
|
@ -35,7 +52,7 @@ class Login extends Component<Stores<'currentUser'>> {
|
||||||
margin="dense"
|
margin="dense"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => (this.username = e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
id="password"
|
id="password"
|
||||||
|
|
@ -46,7 +63,7 @@ class Login extends Component<Stores<'currentUser'>> {
|
||||||
margin="normal"
|
margin="normal"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => (this.password = e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -54,9 +71,12 @@ class Login extends Component<Stores<'currentUser'>> {
|
||||||
size="large"
|
size="large"
|
||||||
className="login"
|
className="login"
|
||||||
color="primary"
|
color="primary"
|
||||||
disabled={!!this.props.currentUser.connectionErrorMessage}
|
disabled={
|
||||||
|
!!currentUser.connectionErrorMessage || currentUser.authenticating
|
||||||
|
}
|
||||||
style={{marginTop: 15, marginBottom: 5}}
|
style={{marginTop: 15, marginBottom: 5}}
|
||||||
onClick={this.login}>
|
loading={currentUser.authenticating}
|
||||||
|
onClick={login}>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -64,34 +84,12 @@ class Login extends Component<Stores<'currentUser'>> {
|
||||||
</Grid>
|
</Grid>
|
||||||
{registerDialog && (
|
{registerDialog && (
|
||||||
<RegistrationDialog
|
<RegistrationDialog
|
||||||
fClose={() => (this.registerDialog = false)}
|
fClose={() => setRegisterDialog(false)}
|
||||||
fOnSubmit={this.props.currentUser.register}
|
fOnSubmit={currentUser.register}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
private login = (e: React.MouseEvent<HTMLButtonElement>) => {
|
export default Login;
|
||||||
e.preventDefault();
|
|
||||||
this.props.currentUser.login(this.username, this.password);
|
|
||||||
};
|
|
||||||
|
|
||||||
private registerButton = () => {
|
|
||||||
if (config.get('register'))
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
id="register"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => (this.registerDialog = true)}>
|
|
||||||
Register
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
else return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
private preventDefault = (e: FormEvent<HTMLFormElement>) => e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default inject('currentUser')(Login);
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import TextField from '@material-ui/core/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Tooltip from '@material-ui/core/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import React, {ChangeEvent, Component} from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -13,22 +13,20 @@ interface IProps {
|
||||||
fOnSubmit: (name: string, pass: string) => Promise<boolean>;
|
fOnSubmit: (name: string, pass: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
const RegistrationDialog = ({fClose, fOnSubmit, name: initialName = ''}: IProps) => {
|
||||||
name: string;
|
const [name, setName] = React.useState(initialName);
|
||||||
pass: string;
|
const [pass, setPass] = React.useState('');
|
||||||
}
|
const namePresent = name.length !== 0;
|
||||||
|
const passPresent = pass.length !== 0;
|
||||||
|
|
||||||
export default class RegistrationDialog extends Component<IProps, IState> {
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
public state = {
|
setName(e.target.value);
|
||||||
name: '',
|
};
|
||||||
pass: '',
|
|
||||||
|
const handlePassChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPass(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {fClose, fOnSubmit} = this.props;
|
|
||||||
const {name, pass} = this.state;
|
|
||||||
const namePresent = this.state.name.length !== 0;
|
|
||||||
const passPresent = this.state.pass.length !== 0;
|
|
||||||
const submitAndClose = (): void => {
|
const submitAndClose = (): void => {
|
||||||
fOnSubmit(name, pass).then((success) => {
|
fOnSubmit(name, pass).then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|
@ -36,6 +34,7 @@ export default class RegistrationDialog extends Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={true}
|
open={true}
|
||||||
|
|
@ -53,7 +52,7 @@ export default class RegistrationDialog extends Component<IProps, IState> {
|
||||||
name="username"
|
name="username"
|
||||||
value={name}
|
value={name}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
onChange={this.handleChange.bind(this, 'name')}
|
onChange={handleNameChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -66,7 +65,7 @@ export default class RegistrationDialog extends Component<IProps, IState> {
|
||||||
label="Password *"
|
label="Password *"
|
||||||
name="password"
|
name="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onChange={this.handleChange.bind(this, 'pass')}
|
onChange={handlePassChange}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
@ -94,11 +93,5 @@ export default class RegistrationDialog extends Component<IProps, IState> {
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
export default RegistrationDialog;
|
||||||
private handleChange(propertyName: string, event: ChangeEvent<HTMLInputElement>) {
|
|
||||||
const state = this.state;
|
|
||||||
state[propertyName] = event.target.value;
|
|
||||||
this.setState(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import {BaseStore} from '../common/BaseStore';
|
import {BaseStore} from '../common/BaseStore';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import {action} from 'mobx';
|
import {action, makeObservable} from 'mobx';
|
||||||
import {SnackReporter} from '../snack/SnackManager';
|
import {SnackReporter} from '../snack/SnackManager';
|
||||||
import {IUser} from '../types';
|
import {IUser} from '../types';
|
||||||
|
|
||||||
export class UserStore extends BaseStore<IUser> {
|
export class UserStore extends BaseStore<IUser> {
|
||||||
constructor(private readonly snack: SnackReporter) {
|
constructor(private readonly snack: SnackReporter) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
makeObservable(this, {
|
||||||
|
create: action,
|
||||||
|
update: action,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected requestItems = (): Promise<IUser[]> =>
|
protected requestItems = (): Promise<IUser[]> =>
|
||||||
|
|
@ -19,14 +24,12 @@ export class UserStore extends BaseStore<IUser> {
|
||||||
.then(() => this.snack('User deleted'));
|
.then(() => this.snack('User deleted'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
|
||||||
public create = async (name: string, pass: string, admin: boolean) => {
|
public create = async (name: string, pass: string, admin: boolean) => {
|
||||||
await axios.post(`${config.get('url')}user`, {name, pass, admin});
|
await axios.post(`${config.get('url')}user`, {name, pass, admin});
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
this.snack('User created');
|
this.snack('User created');
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
|
||||||
public update = async (id: number, name: string, pass: string | null, admin: boolean) => {
|
public update = async (id: number, name: string, pass: string | null, admin: boolean) => {
|
||||||
await axios.post(config.get('url') + 'user/' + id, {name, pass, admin});
|
await axios.post(config.get('url') + 'user/' + id, {name, pass, admin});
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,21 @@
|
||||||
import Grid from '@material-ui/core/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import IconButton from '@material-ui/core/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Paper from '@material-ui/core/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import {withStyles, WithStyles} from '@material-ui/core/styles';
|
import Table from '@mui/material/Table';
|
||||||
import Table from '@material-ui/core/Table';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableBody from '@material-ui/core/TableBody';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableCell from '@material-ui/core/TableCell';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableHead from '@material-ui/core/TableHead';
|
import TableRow from '@mui/material/TableRow';
|
||||||
import TableRow from '@material-ui/core/TableRow';
|
import Delete from '@mui/icons-material/Delete';
|
||||||
import Delete from '@material-ui/icons/Delete';
|
import Edit from '@mui/icons-material/Edit';
|
||||||
import Edit from '@material-ui/icons/Edit';
|
import React from 'react';
|
||||||
import React, {Component, SFC} from 'react';
|
|
||||||
import ConfirmDialog from '../common/ConfirmDialog';
|
import ConfirmDialog from '../common/ConfirmDialog';
|
||||||
import DefaultPage from '../common/DefaultPage';
|
import DefaultPage from '../common/DefaultPage';
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@mui/material/Button';
|
||||||
import AddEditDialog from './AddEditUserDialog';
|
import AddEditDialog from './AddEditUserDialog';
|
||||||
import {observer} from 'mobx-react';
|
|
||||||
import {observable} from 'mobx';
|
|
||||||
import {inject, Stores} from '../inject';
|
|
||||||
import {IUser} from '../types';
|
import {IUser} from '../types';
|
||||||
|
import {useStores} from '../stores';
|
||||||
const styles = () => ({
|
import {observer} from 'mobx-react';
|
||||||
wrapper: {
|
|
||||||
margin: '0 auto',
|
|
||||||
maxWidth: 700,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
interface IRowProps {
|
interface IRowProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -33,39 +24,27 @@ interface IRowProps {
|
||||||
fEdit: VoidFunction;
|
fEdit: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserRow: SFC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
|
const UserRow: React.FC<IRowProps> = ({name, admin, fDelete, fEdit}) => (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>{name}</TableCell>
|
<TableCell>{name}</TableCell>
|
||||||
<TableCell>{admin ? 'Yes' : 'No'}</TableCell>
|
<TableCell>{admin ? 'Yes' : 'No'}</TableCell>
|
||||||
<TableCell align="right" padding="none">
|
<TableCell align="right" padding="none">
|
||||||
<IconButton onClick={fEdit} className="edit">
|
<IconButton onClick={fEdit} className="edit" size="large">
|
||||||
<Edit />
|
<Edit />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton onClick={fDelete} className="delete">
|
<IconButton onClick={fDelete} className="delete" size="large">
|
||||||
<Delete />
|
<Delete />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|
||||||
@observer
|
const Users = observer(() => {
|
||||||
class Users extends Component<WithStyles<'wrapper'> & Stores<'userStore'>> {
|
const [deleteUser, setDeleteUser] = React.useState<IUser>();
|
||||||
@observable
|
const [editUser, setEditUser] = React.useState<IUser>();
|
||||||
private createDialog = false;
|
const [createDialog, setCreateDialog] = React.useState(false);
|
||||||
@observable
|
const {userStore} = useStores();
|
||||||
private deleteId: number | false = false;
|
React.useEffect(() => void userStore.refresh(), []);
|
||||||
@observable
|
|
||||||
private editId: number | false = false;
|
|
||||||
|
|
||||||
public componentDidMount = () => this.props.userStore.refresh();
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
const {
|
|
||||||
deleteId,
|
|
||||||
editId,
|
|
||||||
createDialog,
|
|
||||||
props: {userStore},
|
|
||||||
} = this;
|
|
||||||
const users = userStore.getItems();
|
const users = userStore.getItems();
|
||||||
return (
|
return (
|
||||||
<DefaultPage
|
<DefaultPage
|
||||||
|
|
@ -75,11 +54,11 @@ class Users extends Component<WithStyles<'wrapper'> & Stores<'userStore'>> {
|
||||||
id="create-user"
|
id="create-user"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => (this.createDialog = true)}>
|
onClick={() => setCreateDialog(true)}>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
}>
|
}>
|
||||||
<Grid item xs={12}>
|
<Grid size={{xs: 12}}>
|
||||||
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
<Paper elevation={6} style={{overflowX: 'auto'}}>
|
||||||
<Table id="user-table">
|
<Table id="user-table">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
|
@ -95,8 +74,8 @@ class Users extends Component<WithStyles<'wrapper'> & Stores<'userStore'>> {
|
||||||
key={user.id}
|
key={user.id}
|
||||||
name={user.name}
|
name={user.name}
|
||||||
admin={user.admin}
|
admin={user.admin}
|
||||||
fDelete={() => (this.deleteId = user.id)}
|
fDelete={() => setDeleteUser(user)}
|
||||||
fEdit={() => (this.editId = user.id)}
|
fEdit={() => setEditUser(user)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
@ -104,31 +83,27 @@ class Users extends Component<WithStyles<'wrapper'> & Stores<'userStore'>> {
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
{createDialog && (
|
{createDialog && (
|
||||||
<AddEditDialog
|
<AddEditDialog fClose={() => setCreateDialog(false)} fOnSubmit={userStore.create} />
|
||||||
fClose={() => (this.createDialog = false)}
|
|
||||||
fOnSubmit={userStore.create}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{editId !== false && (
|
{editUser && (
|
||||||
<AddEditDialog
|
<AddEditDialog
|
||||||
fClose={() => (this.editId = false)}
|
fClose={() => setEditUser(undefined)}
|
||||||
fOnSubmit={userStore.update.bind(this, editId)}
|
fOnSubmit={userStore.update.bind(this, editUser.id)}
|
||||||
name={userStore.getByID(editId).name}
|
name={editUser.name}
|
||||||
admin={userStore.getByID(editId).admin}
|
admin={editUser.admin}
|
||||||
isEdit={true}
|
isEdit={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{deleteId !== false && (
|
{deleteUser && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title="Confirm Delete"
|
title="Confirm Delete"
|
||||||
text={'Delete ' + userStore.getByID(deleteId).name + '?'}
|
text={'Delete ' + deleteUser.name + '?'}
|
||||||
fClose={() => (this.deleteId = false)}
|
fClose={() => setDeleteUser(undefined)}
|
||||||
fOnSubmit={() => userStore.remove(deleteId)}
|
fOnSubmit={() => userStore.remove(deleteUser.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DefaultPage>
|
</DefaultPage>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export default withStyles(styles)(inject('userStore')(Users));
|
export default Users;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "src",
|
"target": "ES2020",
|
||||||
"outDir": "build/dist",
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"target": "es5",
|
"allowImportingTsExtensions": true,
|
||||||
"lib": [
|
|
||||||
"es6",
|
|
||||||
"dom"
|
|
||||||
],
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
|
|
@ -17,10 +14,8 @@
|
||||||
"noImplicitThis": true,
|
"noImplicitThis": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"suppressImplicitAnyIndexErrors": true,
|
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"experimentalDecorators": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
@ -28,7 +23,6 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"keyofStringsOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Example: vite-env.d.ts
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import {defineConfig} from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const GOTIFY_SERVER_PORT = process.env.GOTIFY_SERVER_PORT ?? '80';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: 'build',
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
assetsDir: 'static',
|
||||||
|
},
|
||||||
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
// Some libraries use the global object, even though it doesn't exist in the browser.
|
||||||
|
// Alternatively, we could add `<script>window.global = window;</script>` to index.html.
|
||||||
|
// https://github.com/vitejs/vite/discussions/5912
|
||||||
|
global: {},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
proxy: {
|
||||||
|
'^/(application|message|client|current|user|plugin|version|image)': {
|
||||||
|
target: `http://localhost:${GOTIFY_SERVER_PORT}/`,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/stream': {
|
||||||
|
target: `ws://localhost:${GOTIFY_SERVER_PORT}/`,
|
||||||
|
ws: true,
|
||||||
|
rewriteWsOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cors: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import {defineConfig} from 'vitest/config';
|
||||||
|
|
||||||
|
const timeout = process.env.CI === 'true' ? 60000 : 30000;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
testTimeout: timeout,
|
||||||
|
hookTimeout: timeout,
|
||||||
|
},
|
||||||
|
});
|
||||||
14818
ui/yarn.lock
14818
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue