mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-20 02:00:49 +00:00
Add stulbe page and feedback on saving
This commit is contained in:
parent
8bb2f79fd9
commit
2703c39df8
14 changed files with 398 additions and 94 deletions
|
@ -9,6 +9,7 @@ module.exports = {
|
||||||
'no-shadow': 'off',
|
'no-shadow': 'off',
|
||||||
'@typescript-eslint/no-use-before-define': ['error'],
|
'@typescript-eslint/no-use-before-define': ['error'],
|
||||||
'@typescript-eslint/no-shadow': ['error'],
|
'@typescript-eslint/no-shadow': ['error'],
|
||||||
|
'default-case': 'off',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
'import/resolver': {
|
'import/resolver': {
|
||||||
|
|
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
|
@ -922,6 +922,11 @@
|
||||||
"readdirp": "~3.5.0"
|
"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": {
|
"color-convert": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
|
@ -2520,6 +2525,14 @@
|
||||||
"react-router": "6.1.1"
|
"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": {
|
"read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
"react-i18next": "^11.12.0",
|
"react-i18next": "^11.12.0",
|
||||||
"react-redux": "^7.2.4",
|
"react-redux": "^7.2.4",
|
||||||
"react-router-dom": "^6.1.1",
|
"react-router-dom": "^6.1.1",
|
||||||
|
"react-toastify": "^8.1.0",
|
||||||
"redux-devtools-extension": "^2.13.9",
|
"redux-devtools-extension": "^2.13.9",
|
||||||
"redux-thunk": "^2.3.0",
|
"redux-thunk": "^2.3.0",
|
||||||
"sass": "^1.32.12",
|
"sass": "^1.32.12",
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import 'inter-ui/inter.css';
|
import 'inter-ui/inter.css';
|
||||||
import 'normalize.css/normalize.css';
|
import 'normalize.css/normalize.css';
|
||||||
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
|
|
||||||
import './locale/setup';
|
import './locale/setup';
|
||||||
import './style.css';
|
import './style.css';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ActionCreatorWithOptionalPayload, AsyncThunk } from '@reduxjs/toolkit';
|
import { ActionCreatorWithOptionalPayload, AsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
KilovoltMessage,
|
KilovoltMessage,
|
||||||
|
@ -9,6 +9,11 @@ import { RootState } from '../store';
|
||||||
import apiReducer, { getUserPoints } from '../store/api/reducer';
|
import apiReducer, { getUserPoints } from '../store/api/reducer';
|
||||||
import { APIState, LoyaltyStorage, RequestStatus } from '../store/api/types';
|
import { APIState, LoyaltyStorage, RequestStatus } from '../store/api/types';
|
||||||
|
|
||||||
|
interface LoadStatus {
|
||||||
|
load: RequestStatus;
|
||||||
|
save: RequestStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export function useModule<T>({
|
export function useModule<T>({
|
||||||
key,
|
key,
|
||||||
selector,
|
selector,
|
||||||
|
@ -27,7 +32,7 @@ export function useModule<T>({
|
||||||
T,
|
T,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
AsyncThunk<KilovoltMessage, T, {}>,
|
AsyncThunk<KilovoltMessage, T, {}>,
|
||||||
{ load: RequestStatus; save: RequestStatus },
|
LoadStatus,
|
||||||
] {
|
] {
|
||||||
const client = useSelector((state: RootState) => state.api.client);
|
const client = useSelector((state: RootState) => state.api.client);
|
||||||
const data = useSelector((state: RootState) => selector(state.api));
|
const data = useSelector((state: RootState) => selector(state.api));
|
||||||
|
@ -46,6 +51,9 @@ export function useModule<T>({
|
||||||
client.subscribeKey(key, subscriber);
|
client.subscribeKey(key, subscriber);
|
||||||
return () => {
|
return () => {
|
||||||
client.unsubscribeKey(key, subscriber);
|
client.unsubscribeKey(key, subscriber);
|
||||||
|
dispatch(
|
||||||
|
apiReducer.actions.requestKeysRemoved([`save-${key}`, `load-${key}`]),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return [
|
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 {
|
export function useUserPoints(): LoyaltyStorage {
|
||||||
const prefix = 'loyalty/points/';
|
const prefix = 'loyalty/points/';
|
||||||
const client = useSelector((state: RootState) => state.api.client);
|
const client = useSelector((state: RootState) => state.api.client);
|
||||||
|
@ -72,7 +99,7 @@ export function useUserPoints(): LoyaltyStorage {
|
||||||
};
|
};
|
||||||
client.subscribePrefix(prefix, subscriber);
|
client.subscribePrefix(prefix, subscriber);
|
||||||
return () => {
|
return () => {
|
||||||
client.subscribePrefix(prefix, subscriber);
|
client.unsubscribePrefix(prefix, subscriber);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return data;
|
return data;
|
||||||
|
@ -80,5 +107,6 @@ export function useUserPoints(): LoyaltyStorage {
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
useModule,
|
useModule,
|
||||||
|
useStatus,
|
||||||
useUserPoints,
|
useUserPoints,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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.",
|
"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-path": "Static assets (leave empty to disable)",
|
||||||
"static-placeholder": "Absolute path to static assets",
|
"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": {
|
"form-actions": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"saving": "Saving...",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"error": "Error"
|
"error": "Error"
|
||||||
},
|
},
|
||||||
|
|
|
@ -283,6 +283,11 @@ const apiReducer = createSlice({
|
||||||
) {
|
) {
|
||||||
state.loyalty.users[user] = entry;
|
state.loyalty.users[user] = entry;
|
||||||
},
|
},
|
||||||
|
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
|
||||||
|
payload.forEach((key) => {
|
||||||
|
delete state.requestStatus[key];
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
|
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
|
||||||
|
@ -294,25 +299,43 @@ const apiReducer = createSlice({
|
||||||
});
|
});
|
||||||
Object.values(modules).forEach((mod) => {
|
Object.values(modules).forEach((mod) => {
|
||||||
builder.addCase(mod.getter.pending, (state) => {
|
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) => {
|
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);
|
mod.stateSetter(state, action);
|
||||||
});
|
});
|
||||||
builder.addCase(mod.getter.rejected, (state) => {
|
builder.addCase(mod.getter.rejected, (state, { error }) => {
|
||||||
state.requestStatus[`load-${mod.key}`] = 'error';
|
state.requestStatus[`load-${mod.key}`] = {
|
||||||
// TODO Report error
|
type: 'error',
|
||||||
|
error: error.message,
|
||||||
|
updated: new Date(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.addCase(mod.setter.pending, (state) => {
|
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) => {
|
builder.addCase(mod.setter.fulfilled, (state) => {
|
||||||
state.requestStatus[`save-${mod.key}`] = 'success';
|
state.requestStatus[`save-${mod.key}`] = {
|
||||||
|
type: 'success',
|
||||||
|
updated: new Date(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.addCase(mod.setter.rejected, (state) => {
|
builder.addCase(mod.setter.rejected, (state, { error }) => {
|
||||||
state.requestStatus[`save-${mod.key}`] = 'error';
|
state.requestStatus[`save-${mod.key}`] = {
|
||||||
// TODO Report error
|
type: 'error',
|
||||||
|
error: error.message,
|
||||||
|
updated: new Date(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
||||||
});
|
});
|
||||||
|
|
|
@ -154,7 +154,10 @@ export enum ConnectionStatus {
|
||||||
Connected,
|
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 {
|
export interface APIState {
|
||||||
client: KilovoltWS;
|
client: KilovoltWS;
|
||||||
|
|
|
@ -21,3 +21,8 @@ html {
|
||||||
font-family: 'Inter var', 'system-ui';
|
font-family: 'Inter var', 'system-ui';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
a:visited {
|
||||||
|
color: var(--teal11);
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,11 @@ import {
|
||||||
TimerIcon,
|
TimerIcon,
|
||||||
} from '@radix-ui/react-icons';
|
} from '@radix-ui/react-icons';
|
||||||
import { Route, Routes } from 'react-router-dom';
|
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 { RootState } from '../store';
|
||||||
import { createWSClient } from '../store/api/reducer';
|
import { createWSClient } from '../store/api/reducer';
|
||||||
import { ConnectionStatus } from '../store/api/types';
|
import { ConnectionStatus } from '../store/api/types';
|
||||||
|
@ -19,9 +24,7 @@ import { styled } from './theme';
|
||||||
|
|
||||||
// @ts-expect-error Asset import
|
// @ts-expect-error Asset import
|
||||||
import spinner from '../assets/icon-loading.svg';
|
import spinner from '../assets/icon-loading.svg';
|
||||||
import Dashboard from './pages/Dashboard';
|
import BackendIntegrationPage from './pages/BackendIntegration';
|
||||||
import Sidebar, { RouteSection } from './components/Sidebar';
|
|
||||||
import ServerSettingsPage from './pages/ServerSettings';
|
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
const LoadingDiv = styled('div', {
|
const LoadingDiv = styled('div', {
|
||||||
|
@ -74,7 +77,7 @@ const sections: RouteSection[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'menu.pages.strimertul.stulbe',
|
title: 'menu.pages.strimertul.stulbe',
|
||||||
url: '/stulbe',
|
url: '/backend',
|
||||||
icon: <Link2Icon />,
|
icon: <Link2Icon />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -167,7 +170,9 @@ export default function App(): JSX.Element {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/http" element={<ServerSettingsPage />} />
|
<Route path="/http" element={<ServerSettingsPage />} />
|
||||||
|
<Route path="/backend" element={<BackendIntegrationPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<ToastContainer position="bottom-center" autoClose={5000} theme="dark" />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
34
frontend/src/ui/components/utils/SaveButton.tsx
Normal file
34
frontend/src/ui/components/utils/SaveButton.tsx
Normal 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);
|
128
frontend/src/ui/pages/BackendIntegration.tsx
Normal file
128
frontend/src/ui/pages/BackendIntegration.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useDispatch } from 'react-redux';
|
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 apiReducer, { modules } from '../../store/api/reducer';
|
||||||
|
import SaveButton from '../components/utils/SaveButton';
|
||||||
import {
|
import {
|
||||||
Button,
|
|
||||||
Field,
|
Field,
|
||||||
FieldNote,
|
FieldNote,
|
||||||
InputBox,
|
InputBox,
|
||||||
|
@ -20,83 +20,90 @@ export default function ServerSettingsPage(): React.ReactElement {
|
||||||
);
|
);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dispatch = useDispatch();
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>{t('pages.http.title')}</PageTitle>
|
<PageTitle>{t('pages.http.title')}</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<Field size="fullWidth">
|
<form
|
||||||
<Label htmlFor="bind">{t('pages.http.bind')}</Label>
|
onSubmit={(ev) => {
|
||||||
<InputBox
|
dispatch(setServerConfig(serverConfig));
|
||||||
type="text"
|
ev.preventDefault();
|
||||||
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))}
|
|
||||||
>
|
>
|
||||||
{t('form-actions.save')}
|
<Field size="fullWidth">
|
||||||
</Button>
|
<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>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { createStitches } from '@stitches/react';
|
||||||
import * as UnstyledLabel from '@radix-ui/react-label';
|
import * as UnstyledLabel from '@radix-ui/react-label';
|
||||||
|
|
||||||
|
@ -8,6 +14,8 @@ export const { styled, theme } = createStitches({
|
||||||
...grayDark,
|
...grayDark,
|
||||||
...tealDark,
|
...tealDark,
|
||||||
...yellowDark,
|
...yellowDark,
|
||||||
|
...grassDark,
|
||||||
|
...redDark,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -68,6 +76,16 @@ export const InputBox = styled('input', {
|
||||||
borderColor: '$teal7',
|
borderColor: '$teal7',
|
||||||
backgroundColor: '$gray3',
|
backgroundColor: '$gray3',
|
||||||
},
|
},
|
||||||
|
'&:disabled': {
|
||||||
|
backgroundColor: '$gray4',
|
||||||
|
borderColor: '$gray5',
|
||||||
|
color: '$gray8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ButtonGroup = styled('div', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Button = styled('button', {
|
export const Button = styled('button', {
|
||||||
|
@ -85,6 +103,31 @@ export const Button = styled('button', {
|
||||||
'&:active': {
|
'&:active': {
|
||||||
background: '$teal6',
|
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 };
|
export default { styled, theme };
|
||||||
|
|
Loading…
Reference in a new issue