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

Add stulbe page and feedback on saving

This commit is contained in:
Ash Keel 2021-12-15 12:12:19 +01:00
parent 8bb2f79fd9
commit 2703c39df8
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
14 changed files with 398 additions and 94 deletions

View file

@ -9,6 +9,7 @@ module.exports = {
'no-shadow': 'off',
'@typescript-eslint/no-use-before-define': ['error'],
'@typescript-eslint/no-shadow': ['error'],
'default-case': 'off',
},
settings: {
'import/resolver': {

View file

@ -922,6 +922,11 @@
"readdirp": "~3.5.0"
}
},
"clsx": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz",
"integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA=="
},
"color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -2520,6 +2525,14 @@
"react-router": "6.1.1"
}
},
"react-toastify": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-8.1.0.tgz",
"integrity": "sha512-M+Q3rTmEw/53Csr7NsV/YnldJe4c7uERcY7Tma9mvLU98QT2VhIkKwjBzzxZkJRk/oBKyUAtkyMjMgO00hx6gQ==",
"requires": {
"clsx": "^1.1.1"
}
},
"read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",

View file

@ -23,6 +23,7 @@
"react-i18next": "^11.12.0",
"react-redux": "^7.2.4",
"react-router-dom": "^6.1.1",
"react-toastify": "^8.1.0",
"redux-devtools-extension": "^2.13.9",
"redux-thunk": "^2.3.0",
"sass": "^1.32.12",

View file

@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
import 'inter-ui/inter.css';
import 'normalize.css/normalize.css';
import 'react-toastify/dist/ReactToastify.css';
import './locale/setup';
import './style.css';

View file

@ -1,5 +1,5 @@
import { ActionCreatorWithOptionalPayload, AsyncThunk } from '@reduxjs/toolkit';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
KilovoltMessage,
@ -9,6 +9,11 @@ import { RootState } from '../store';
import apiReducer, { getUserPoints } from '../store/api/reducer';
import { APIState, LoyaltyStorage, RequestStatus } from '../store/api/types';
interface LoadStatus {
load: RequestStatus;
save: RequestStatus;
}
export function useModule<T>({
key,
selector,
@ -27,7 +32,7 @@ export function useModule<T>({
T,
// eslint-disable-next-line @typescript-eslint/ban-types
AsyncThunk<KilovoltMessage, T, {}>,
{ load: RequestStatus; save: RequestStatus },
LoadStatus,
] {
const client = useSelector((state: RootState) => state.api.client);
const data = useSelector((state: RootState) => selector(state.api));
@ -46,6 +51,9 @@ export function useModule<T>({
client.subscribeKey(key, subscriber);
return () => {
client.unsubscribeKey(key, subscriber);
dispatch(
apiReducer.actions.requestKeysRemoved([`save-${key}`, `load-${key}`]),
);
};
}, []);
return [
@ -58,6 +66,25 @@ export function useModule<T>({
];
}
export function useStatus(
status: RequestStatus | null,
interval = 5000,
): RequestStatus | null {
const [localStatus, setlocalStatus] = useState(status);
const maxTime = Date.now() - interval;
useEffect(() => {
const remaining = status?.updated.getTime() - maxTime;
if (remaining) {
setTimeout(() => {
setlocalStatus(null);
}, remaining);
}
setlocalStatus(status);
}, [status]);
return status?.updated.getTime() > maxTime ? localStatus : null;
}
export function useUserPoints(): LoyaltyStorage {
const prefix = 'loyalty/points/';
const client = useSelector((state: RootState) => state.api.client);
@ -72,7 +99,7 @@ export function useUserPoints(): LoyaltyStorage {
};
client.subscribePrefix(prefix, subscriber);
return () => {
client.subscribePrefix(prefix, subscriber);
client.unsubscribePrefix(prefix, subscriber);
};
}, []);
return data;
@ -80,5 +107,6 @@ export function useUserPoints(): LoyaltyStorage {
export default {
useModule,
useStatus,
useUserPoints,
};

View file

@ -37,11 +37,23 @@
"bind-help": "Every application that uses strimertul will need to be updated, your browser will be redirected to the new URL automatically.",
"static-path": "Static assets (leave empty to disable)",
"static-placeholder": "Absolute path to static assets",
"static-help": "Will be served at the following URL: {{url}}"
"static-help": "Will be served at the following URL: {{url}}",
"saving": "Saving webserver settings..."
},
"stulbe": {
"title": "Back-end integration",
"test-success": "Connection test was successful!",
"test-button": "Test connection",
"endpoint": "Back-end endpoint",
"auth-key": "Authorization key",
"username": "User name",
"subtitle": "Optional back-end integration (using <1>stulbe</1> or any Kilovolt compatible endpoint) for syncing keys and obtaining webhook events",
"bind-placeholder": "HTTP endpoint, leave empty to disable integration"
}
},
"form-actions": {
"save": "Save",
"saving": "Saving...",
"saved": "Saved",
"error": "Error"
},

View file

@ -283,6 +283,11 @@ const apiReducer = createSlice({
) {
state.loyalty.users[user] = entry;
},
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
payload.forEach((key) => {
delete state.requestStatus[key];
});
},
},
extraReducers: (builder) => {
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
@ -294,25 +299,43 @@ const apiReducer = createSlice({
});
Object.values(modules).forEach((mod) => {
builder.addCase(mod.getter.pending, (state) => {
state.requestStatus[`load-${mod.key}`] = 'pending';
state.requestStatus[`load-${mod.key}`] = {
type: 'pending',
updated: new Date(),
};
});
builder.addCase(mod.getter.fulfilled, (state, action) => {
state.requestStatus[`load-${mod.key}`] = 'success';
state.requestStatus[`load-${mod.key}`] = {
type: 'success',
updated: new Date(),
};
mod.stateSetter(state, action);
});
builder.addCase(mod.getter.rejected, (state) => {
state.requestStatus[`load-${mod.key}`] = 'error';
// TODO Report error
builder.addCase(mod.getter.rejected, (state, { error }) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'error',
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.setter.pending, (state) => {
state.requestStatus[`save-${mod.key}`] = 'pending';
state.requestStatus[`save-${mod.key}`] = {
type: 'pending',
updated: new Date(),
};
});
builder.addCase(mod.setter.fulfilled, (state, action) => {
state.requestStatus[`save-${mod.key}`] = 'success';
builder.addCase(mod.setter.fulfilled, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'success',
updated: new Date(),
};
});
builder.addCase(mod.setter.rejected, (state) => {
state.requestStatus[`save-${mod.key}`] = 'error';
// TODO Report error
builder.addCase(mod.setter.rejected, (state, { error }) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'error',
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.asyncSetter, mod.stateSetter);
});

View file

@ -154,7 +154,10 @@ export enum ConnectionStatus {
Connected,
}
export type RequestStatus = 'pending' | 'success' | 'error';
export type RequestStatus =
| { type: 'pending'; updated: Date }
| { type: 'success'; updated: Date }
| { type: 'error'; updated: Date; error: string };
export interface APIState {
client: KilovoltWS;

View file

@ -21,3 +21,8 @@ html {
font-family: 'Inter var', 'system-ui';
}
}
a,
a:visited {
color: var(--teal11);
}

View file

@ -12,6 +12,11 @@ import {
TimerIcon,
} from '@radix-ui/react-icons';
import { Route, Routes } from 'react-router-dom';
import { ToastContainer } from 'react-toastify';
import Dashboard from './pages/Dashboard';
import Sidebar, { RouteSection } from './components/Sidebar';
import ServerSettingsPage from './pages/ServerSettings';
import { RootState } from '../store';
import { createWSClient } from '../store/api/reducer';
import { ConnectionStatus } from '../store/api/types';
@ -19,9 +24,7 @@ import { styled } from './theme';
// @ts-expect-error Asset import
import spinner from '../assets/icon-loading.svg';
import Dashboard from './pages/Dashboard';
import Sidebar, { RouteSection } from './components/Sidebar';
import ServerSettingsPage from './pages/ServerSettings';
import BackendIntegrationPage from './pages/BackendIntegration';
function Loading() {
const LoadingDiv = styled('div', {
@ -74,7 +77,7 @@ const sections: RouteSection[] = [
},
{
title: 'menu.pages.strimertul.stulbe',
url: '/stulbe',
url: '/backend',
icon: <Link2Icon />,
},
],
@ -167,7 +170,9 @@ export default function App(): JSX.Element {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/http" element={<ServerSettingsPage />} />
<Route path="/backend" element={<BackendIntegrationPage />} />
</Routes>
<ToastContainer position="bottom-center" autoClose={5000} theme="dark" />
</Container>
);
}

View file

@ -0,0 +1,34 @@
import { CheckIcon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RequestStatus } from '../../../store/api/types';
import { Button } from '../../theme';
interface SaveButtonProps {
status: RequestStatus;
}
function SaveButton(
props: SaveButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
const { t } = useTranslation();
switch (props.status?.type) {
case 'success':
return (
<Button variation="success" {...props}>
{t('form-actions.saved')} <CheckIcon />
</Button>
);
case 'error':
return (
<Button variation="error" {...props}>
{t('form-actions.error')}
</Button>
);
default:
return <Button>{t('form-actions.save')}</Button>;
}
}
export default React.memo(SaveButton);

View file

@ -0,0 +1,128 @@
import React from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { toast } from 'react-toastify';
import { useModule, useStatus } from '../../lib/react-utils';
import Stulbe from '../../lib/stulbe-lib';
import apiReducer, { modules } from '../../store/api/reducer';
import SaveButton from '../components/utils/SaveButton';
import {
Button,
ButtonGroup,
Field,
InputBox,
Label,
PageContainer,
PageHeader,
PageTitle,
} from '../theme';
export default function BackendIntegrationPage(): React.ReactElement {
const [stulbeConfig, setStulbeConfig, loadStatus] = useModule(
modules.stulbeConfig,
);
const { t } = useTranslation();
const dispatch = useDispatch();
const status = useStatus(loadStatus.save);
const active = stulbeConfig?.enabled ?? false;
const busy =
loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
const test = async () => {
try {
const client = new Stulbe(stulbeConfig.endpoint);
await client.auth(stulbeConfig.username, stulbeConfig.auth_key);
toast.success(t('pages.stulbe.test-success'));
} catch (e) {
toast.error(e.message);
}
};
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.stulbe.title')}</PageTitle>
<p>
<Trans i18nKey="pages.stulbe.subtitle">
{'Optional back-end integration (using '}
<a href="https://github.com/strimertul/stulbe/">stulbe</a> or any
Kilovolt compatible endpoint) for syncing keys and obtaining webhook
events
</Trans>
</p>
</PageHeader>
<form
onSubmit={(ev) => {
dispatch(setStulbeConfig(stulbeConfig));
ev.preventDefault();
}}
>
<Field size="fullWidth">
<Label htmlFor="endpoint">{t('pages.stulbe.endpoint')}</Label>
<InputBox
type="text"
id="endpoint"
placeholder={t('pages.stulbe.bind-placeholder')}
value={stulbeConfig?.endpoint ?? ''}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.stulbeConfigChanged({
...stulbeConfig,
enabled: e.target.value.length > 0,
endpoint: e.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="username">{t('pages.stulbe.username')}</Label>
<InputBox
type="text"
id="username"
value={stulbeConfig?.username ?? ''}
required={true}
disabled={!active || busy}
onChange={(e) =>
dispatch(
apiReducer.actions.stulbeConfigChanged({
...stulbeConfig,
username: e.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="password">{t('pages.stulbe.auth-key')}</Label>
<InputBox
type="password"
id="password"
value={stulbeConfig?.auth_key ?? ''}
disabled={!active || busy}
required={true}
onChange={(e) =>
dispatch(
apiReducer.actions.stulbeConfigChanged({
...stulbeConfig,
auth_key: e.target.value,
}),
)
}
/>
</Field>
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
disabled={!active || busy}
onClick={() => test()}
>
{t('pages.stulbe.test-button')}
</Button>
</ButtonGroup>
</form>
</PageContainer>
);
}

View file

@ -1,10 +1,10 @@
import React, { useEffect } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useModule } from '../../lib/react-utils';
import { useModule, useStatus } from '../../lib/react-utils';
import apiReducer, { modules } from '../../store/api/reducer';
import SaveButton from '../components/utils/SaveButton';
import {
Button,
Field,
FieldNote,
InputBox,
@ -20,83 +20,90 @@ export default function ServerSettingsPage(): React.ReactElement {
);
const { t } = useTranslation();
const dispatch = useDispatch();
const busy = loadStatus.load !== 'success' || loadStatus.save === 'pending';
const status = useStatus(loadStatus.save);
const busy =
loadStatus.load?.type !== 'success' || loadStatus.save?.type === 'pending';
return (
<PageContainer>
<PageHeader>
<PageTitle>{t('pages.http.title')}</PageTitle>
</PageHeader>
<Field size="fullWidth">
<Label htmlFor="bind">{t('pages.http.bind')}</Label>
<InputBox
type="text"
id="bind"
placeholder={t('pages.http.bind-placeholder')}
value={serverConfig?.bind ?? ''}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
bind: e.target.value,
}),
)
}
/>
<FieldNote>{t('pages.http.bind-help')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="kvpassword">{t('pages.http.kilovolt-password')}</Label>
<InputBox
type="password"
id="kvpassword"
placeholder={t('pages.http.kilovolt-placeholder')}
value={serverConfig?.kv_password ?? ''}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
kv_password: e.target.value,
}),
)
}
/>
<FieldNote>{t('pages.http.kilovolt-placeholder')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="static">{t('pages.http.static-path')}</Label>
<InputBox
type="text"
id="static"
placeholder={t('pages.http.static-placeholder')}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
path: e.target.value,
}),
)
}
value={
serverConfig?.enable_static_server ? serverConfig?.path ?? '' : ''
}
/>
<FieldNote>
{t('pages.http.static-help', {
url: `http://${serverConfig?.bind ?? 'localhost:4337'}/static/`,
})}
</FieldNote>
</Field>
<Button
type="button"
disabled={busy}
onClick={() => dispatch(setServerConfig(serverConfig))}
<form
onSubmit={(ev) => {
dispatch(setServerConfig(serverConfig));
ev.preventDefault();
}}
>
{t('form-actions.save')}
</Button>
<Field size="fullWidth">
<Label htmlFor="bind">{t('pages.http.bind')}</Label>
<InputBox
type="text"
id="bind"
placeholder={t('pages.http.bind-placeholder')}
value={serverConfig?.bind ?? ''}
disabled={busy}
required={true}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
bind: e.target.value,
}),
)
}
/>
<FieldNote>{t('pages.http.bind-help')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="kvpassword">
{t('pages.http.kilovolt-password')}
</Label>
<InputBox
type="password"
id="kvpassword"
placeholder={t('pages.http.kilovolt-placeholder')}
value={serverConfig?.kv_password ?? ''}
disabled={busy}
autoComplete="off"
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
kv_password: e.target.value,
}),
)
}
/>
<FieldNote>{t('pages.http.kilovolt-placeholder')}</FieldNote>
</Field>
<Field size="fullWidth">
<Label htmlFor="static">{t('pages.http.static-path')}</Label>
<InputBox
type="text"
id="static"
placeholder={t('pages.http.static-placeholder')}
disabled={busy}
onChange={(e) =>
dispatch(
apiReducer.actions.httpConfigChanged({
...serverConfig,
path: e.target.value,
}),
)
}
value={
serverConfig?.enable_static_server ? serverConfig?.path ?? '' : ''
}
/>
<FieldNote>
{t('pages.http.static-help', {
url: `http://${serverConfig?.bind ?? 'localhost:4337'}/static/`,
})}
</FieldNote>
</Field>
<SaveButton type="submit" status={status} />
</form>
</PageContainer>
);
}

View file

@ -1,4 +1,10 @@
import { grayDark, tealDark, yellowDark } from '@radix-ui/colors';
import {
grassDark,
grayDark,
redDark,
tealDark,
yellowDark,
} from '@radix-ui/colors';
import { createStitches } from '@stitches/react';
import * as UnstyledLabel from '@radix-ui/react-label';
@ -8,6 +14,8 @@ export const { styled, theme } = createStitches({
...grayDark,
...tealDark,
...yellowDark,
...grassDark,
...redDark,
},
},
});
@ -68,6 +76,16 @@ export const InputBox = styled('input', {
borderColor: '$teal7',
backgroundColor: '$gray3',
},
'&:disabled': {
backgroundColor: '$gray4',
borderColor: '$gray5',
color: '$gray8',
},
});
export const ButtonGroup = styled('div', {
display: 'flex',
gap: '0.5rem',
});
export const Button = styled('button', {
@ -85,6 +103,31 @@ export const Button = styled('button', {
'&:active': {
background: '$teal6',
},
transition: 'all 0.2s',
variants: {
variation: {
success: {
border: '1px solid $grass6',
backgroundColor: '$grass4',
'&:hover': {
backgroundColor: '$grass5',
},
'&:active': {
background: '$grass6',
},
},
error: {
border: '1px solid $red6',
backgroundColor: '$red4',
'&:hover': {
backgroundColor: '$red5',
},
'&:active': {
background: '$red6',
},
},
},
},
});
export default { styled, theme };