diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index cd8f5aa..f64b8b3 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -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': { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 56f52f7..4963cc9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index e0da796..2d1e777 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index d133d46..fa7aa4e 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -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'; diff --git a/frontend/src/lib/react-utils.ts b/frontend/src/lib/react-utils.ts index 154072d..b2ca015 100644 --- a/frontend/src/lib/react-utils.ts +++ b/frontend/src/lib/react-utils.ts @@ -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({ key, selector, @@ -27,7 +32,7 @@ export function useModule({ T, // eslint-disable-next-line @typescript-eslint/ban-types AsyncThunk, - { 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({ 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({ ]; } +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, }; diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 9f48b19..5ba8366 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -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 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" }, diff --git a/frontend/src/store/api/reducer.ts b/frontend/src/store/api/reducer.ts index dfb6b3a..495eb05 100644 --- a/frontend/src/store/api/reducer.ts +++ b/frontend/src/store/api/reducer.ts @@ -283,6 +283,11 @@ const apiReducer = createSlice({ ) { state.loyalty.users[user] = entry; }, + requestKeysRemoved(state, { payload }: PayloadAction) { + 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); }); diff --git a/frontend/src/store/api/types.ts b/frontend/src/store/api/types.ts index 177b884..709814e 100644 --- a/frontend/src/store/api/types.ts +++ b/frontend/src/store/api/types.ts @@ -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; diff --git a/frontend/src/style.css b/frontend/src/style.css index 317b296..ccf0f7d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -21,3 +21,8 @@ html { font-family: 'Inter var', 'system-ui'; } } + +a, +a:visited { + color: var(--teal11); +} diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index 8eb055e..13b65f0 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -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: , }, ], @@ -167,7 +170,9 @@ export default function App(): JSX.Element { } /> } /> + } /> + ); } diff --git a/frontend/src/ui/components/utils/SaveButton.tsx b/frontend/src/ui/components/utils/SaveButton.tsx new file mode 100644 index 0000000..cb8fd1b --- /dev/null +++ b/frontend/src/ui/components/utils/SaveButton.tsx @@ -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, +) { + const { t } = useTranslation(); + + switch (props.status?.type) { + case 'success': + return ( + + ); + case 'error': + return ( + + ); + default: + return ; + } +} + +export default React.memo(SaveButton); diff --git a/frontend/src/ui/pages/BackendIntegration.tsx b/frontend/src/ui/pages/BackendIntegration.tsx new file mode 100644 index 0000000..26d86c4 --- /dev/null +++ b/frontend/src/ui/pages/BackendIntegration.tsx @@ -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 ( + + + {t('pages.stulbe.title')} +

+ + {'Optional back-end integration (using '} + stulbe or any + Kilovolt compatible endpoint) for syncing keys and obtaining webhook + events + +

+
+
{ + dispatch(setStulbeConfig(stulbeConfig)); + ev.preventDefault(); + }} + > + + + + dispatch( + apiReducer.actions.stulbeConfigChanged({ + ...stulbeConfig, + enabled: e.target.value.length > 0, + endpoint: e.target.value, + }), + ) + } + /> + + + + + dispatch( + apiReducer.actions.stulbeConfigChanged({ + ...stulbeConfig, + username: e.target.value, + }), + ) + } + /> + + + + + dispatch( + apiReducer.actions.stulbeConfigChanged({ + ...stulbeConfig, + auth_key: e.target.value, + }), + ) + } + /> + + + + + +
+
+ ); +} diff --git a/frontend/src/ui/pages/ServerSettings.tsx b/frontend/src/ui/pages/ServerSettings.tsx index 74ccc1d..ee74f6d 100644 --- a/frontend/src/ui/pages/ServerSettings.tsx +++ b/frontend/src/ui/pages/ServerSettings.tsx @@ -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 ( {t('pages.http.title')} - - - - dispatch( - apiReducer.actions.httpConfigChanged({ - ...serverConfig, - bind: e.target.value, - }), - ) - } - /> - {t('pages.http.bind-help')} - - - - - dispatch( - apiReducer.actions.httpConfigChanged({ - ...serverConfig, - kv_password: e.target.value, - }), - ) - } - /> - {t('pages.http.kilovolt-placeholder')} - - - - - dispatch( - apiReducer.actions.httpConfigChanged({ - ...serverConfig, - path: e.target.value, - }), - ) - } - value={ - serverConfig?.enable_static_server ? serverConfig?.path ?? '' : '' - } - /> - - {t('pages.http.static-help', { - url: `http://${serverConfig?.bind ?? 'localhost:4337'}/static/`, - })} - - - + + + + dispatch( + apiReducer.actions.httpConfigChanged({ + ...serverConfig, + bind: e.target.value, + }), + ) + } + /> + {t('pages.http.bind-help')} + + + + + dispatch( + apiReducer.actions.httpConfigChanged({ + ...serverConfig, + kv_password: e.target.value, + }), + ) + } + /> + {t('pages.http.kilovolt-placeholder')} + + + + + dispatch( + apiReducer.actions.httpConfigChanged({ + ...serverConfig, + path: e.target.value, + }), + ) + } + value={ + serverConfig?.enable_static_server ? serverConfig?.path ?? '' : '' + } + /> + + {t('pages.http.static-help', { + url: `http://${serverConfig?.bind ?? 'localhost:4337'}/static/`, + })} + + + + ); } diff --git a/frontend/src/ui/theme.ts b/frontend/src/ui/theme.ts index dff165b..aa1df63 100644 --- a/frontend/src/ui/theme.ts +++ b/frontend/src/ui/theme.ts @@ -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 };