mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-20 02:00:49 +00:00
Functional sidebar and routing (and a page!)
This commit is contained in:
parent
b8465ceb38
commit
de86fc1af6
16 changed files with 834 additions and 4229 deletions
|
@ -5,14 +5,9 @@
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>strimertül control panel</title>
|
<title>strimertül control panel</title>
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://jenil.github.io/bulmaswatch/darkly/bulmaswatch.min.css"
|
|
||||||
/>
|
|
||||||
<link rel="stylesheet" href="./src/overrides.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="dark-theme">
|
||||||
<div id="main" class="is-fullheight"></div>
|
<div id="main"></div>
|
||||||
<script src="./src/index.tsx" type="module"></script>
|
<script src="./src/index.tsx" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
4345
frontend/package-lock.json
generated
4345
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -3,20 +3,26 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@billjs/event-emitter": "^1.0.3",
|
"@billjs/event-emitter": "^1.0.3",
|
||||||
"@reach/router": "^1.3.4",
|
"@radix-ui/colors": "^0.1.8",
|
||||||
|
"@radix-ui/react-icons": "^1.0.3",
|
||||||
|
"@radix-ui/react-label": "^0.1.3",
|
||||||
"@reduxjs/toolkit": "^1.5.1",
|
"@reduxjs/toolkit": "^1.5.1",
|
||||||
|
"@stitches/react": "^1.2.6",
|
||||||
"@strimertul/kilovolt-client": "^6.2.0",
|
"@strimertul/kilovolt-client": "^6.2.0",
|
||||||
"@types/node": "^15.0.2",
|
"@types/node": "^15.0.2",
|
||||||
"@types/reach__router": "^1.3.7",
|
|
||||||
"@types/react": "^17.0.5",
|
"@types/react": "^17.0.5",
|
||||||
"@types/react-dom": "^17.0.4",
|
"@types/react-dom": "^17.0.4",
|
||||||
"@vitejs/plugin-react": "^1.0.9",
|
"@vitejs/plugin-react": "^1.0.9",
|
||||||
"i18next": "^20.6.1",
|
"i18next": "^20.6.1",
|
||||||
|
"inter-ui": "^3.19.3",
|
||||||
|
"normalize.css": "^8.0.1",
|
||||||
|
"postcss-import": "^14.0.2",
|
||||||
"pretty-ms": "^7.0.1",
|
"pretty-ms": "^7.0.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"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",
|
||||||
"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",
|
||||||
|
|
1
frontend/src/assets/icon-loading.svg
Normal file
1
frontend/src/assets/icon-loading.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2"><style>@keyframes loading{0%{opacity:0;transform:translateY(10px)}50%{opacity:1;transform:translateY(-3px)}100%{opacity:0;transform:translateY(-5px)}}.icon{animation:loading 1s infinite}</style><g transform="rotate(-90 489 788) scale(3.18767)"><path d="m338 136-22 44a17 17 0 0 0 15 25h45a17 17 0 0 0 15-25l-22-44a17 17 0 0 0-31 0Zm4 2a13 13 0 0 1 23 0l22 44a13 13 0 0 1-11 19h-45a13 13 0 0 1-11-19l22-44Z" style="fill:white" class="icon"/></g><g transform="rotate(90 838 -139) scale(3.18767)"><path d="m338 136-22 44a17 17 0 0 0 15 25h45a17 17 0 0 0 15-25l-22-44a17 17 0 0 0-31 0Zm4 2a13 13 0 0 1 23 0l22 44a13 13 0 0 1-11 19h-45a13 13 0 0 1-11-19l22-44Z" style="fill:white" class="icon"/></g></svg>
|
After Width: | Height: | Size: 870 B |
|
@ -1,21 +1,22 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { createHistory, LocationProvider } from '@reach/router';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
import 'inter-ui/inter.css';
|
||||||
|
import 'normalize.css/normalize.css';
|
||||||
|
|
||||||
import './locale/setup';
|
import './locale/setup';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
import App from './ui/App';
|
import App from './ui/App';
|
||||||
|
|
||||||
// @ts-expect-error idk
|
|
||||||
const history = createHistory(window);
|
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<LocationProvider history={history}>
|
<BrowserRouter basename="/ui">
|
||||||
<App />
|
<App />
|
||||||
</LocationProvider>
|
</BrowserRouter>
|
||||||
</Provider>,
|
</Provider>,
|
||||||
document.getElementById('main'),
|
document.getElementById('main'),
|
||||||
);
|
);
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '@strimertul/kilovolt-client';
|
} from '@strimertul/kilovolt-client';
|
||||||
import { RootState } from '../store';
|
import { RootState } from '../store';
|
||||||
import apiReducer, { getUserPoints } from '../store/api/reducer';
|
import apiReducer, { getUserPoints } from '../store/api/reducer';
|
||||||
import { APIState, LoyaltyStorage } from '../store/api/types';
|
import { APIState, LoyaltyStorage, RequestStatus } from '../store/api/types';
|
||||||
|
|
||||||
export function useModule<T>({
|
export function useModule<T>({
|
||||||
key,
|
key,
|
||||||
|
@ -23,10 +23,20 @@ export function useModule<T>({
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
setter: AsyncThunk<KilovoltMessage, T, {}>;
|
setter: AsyncThunk<KilovoltMessage, T, {}>;
|
||||||
asyncSetter: ActionCreatorWithOptionalPayload<T, string>;
|
asyncSetter: ActionCreatorWithOptionalPayload<T, string>;
|
||||||
|
}): [
|
||||||
|
T,
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
}): [T, AsyncThunk<KilovoltMessage, T, {}>] {
|
AsyncThunk<KilovoltMessage, T, {}>,
|
||||||
|
{ load: RequestStatus; save: RequestStatus },
|
||||||
|
] {
|
||||||
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));
|
||||||
|
const loadStatus = useSelector(
|
||||||
|
(state: RootState) => state.api.requestStatus[`load-${key}`],
|
||||||
|
);
|
||||||
|
const saveStatus = useSelector(
|
||||||
|
(state: RootState) => state.api.requestStatus[`save-${key}`],
|
||||||
|
);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(getter());
|
dispatch(getter());
|
||||||
|
@ -38,7 +48,14 @@ export function useModule<T>({
|
||||||
client.unsubscribeKey(key, subscriber);
|
client.unsubscribeKey(key, subscriber);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
return [data, setter];
|
return [
|
||||||
|
data,
|
||||||
|
setter,
|
||||||
|
{
|
||||||
|
load: loadStatus,
|
||||||
|
save: saveStatus,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserPoints(): LoyaltyStorage {
|
export function useUserPoints(): LoyaltyStorage {
|
||||||
|
|
|
@ -1 +1,51 @@
|
||||||
{}
|
{
|
||||||
|
"menu": {
|
||||||
|
"sections": {
|
||||||
|
"monitor": "Monitor",
|
||||||
|
"strimertul": "strimertul",
|
||||||
|
"twitch": "Twitch",
|
||||||
|
"loyalty": "Loyalty system"
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"monitor": {
|
||||||
|
"dashboard": "Dashboard"
|
||||||
|
},
|
||||||
|
"strimertul": {
|
||||||
|
"settings": "Server settings",
|
||||||
|
"stulbe": "Back-end integration"
|
||||||
|
},
|
||||||
|
"twitch": {
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"bot-commands": "Chat commands",
|
||||||
|
"bot-timers": "Chat timers",
|
||||||
|
"bot-alerts": "Chat alerts"
|
||||||
|
},
|
||||||
|
"loyalty": {
|
||||||
|
"configuration": "Configuration",
|
||||||
|
"points": "Points and redeems",
|
||||||
|
"rewards": "Rewards and goals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"http": {
|
||||||
|
"title": "Server settings",
|
||||||
|
"bind-placeholder": "address:port",
|
||||||
|
"bind": "Webserver Bind",
|
||||||
|
"kilovolt-password": "Kilovolt password",
|
||||||
|
"kilovolt-placeholder": "Leave empty to disable authentication",
|
||||||
|
"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}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form-actions": {
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved",
|
||||||
|
"error": "Error"
|
||||||
|
},
|
||||||
|
"debug": {
|
||||||
|
"dev-build": "Development build"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -255,6 +255,7 @@ const initialState: APIState = {
|
||||||
stulbeConfig: null,
|
stulbeConfig: null,
|
||||||
loyaltyConfig: null,
|
loyaltyConfig: null,
|
||||||
},
|
},
|
||||||
|
requestStatus: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiReducer = createSlice({
|
const apiReducer = createSlice({
|
||||||
|
@ -292,7 +293,27 @@ const apiReducer = createSlice({
|
||||||
state.loyalty.users = payload;
|
state.loyalty.users = payload;
|
||||||
});
|
});
|
||||||
Object.values(modules).forEach((mod) => {
|
Object.values(modules).forEach((mod) => {
|
||||||
builder.addCase(mod.getter.fulfilled, mod.stateSetter);
|
builder.addCase(mod.getter.pending, (state) => {
|
||||||
|
state.requestStatus[`load-${mod.key}`] = 'pending';
|
||||||
|
});
|
||||||
|
builder.addCase(mod.getter.fulfilled, (state, action) => {
|
||||||
|
state.requestStatus[`load-${mod.key}`] = 'success';
|
||||||
|
mod.stateSetter(state, action);
|
||||||
|
});
|
||||||
|
builder.addCase(mod.getter.rejected, (state) => {
|
||||||
|
state.requestStatus[`load-${mod.key}`] = 'error';
|
||||||
|
// TODO Report error
|
||||||
|
});
|
||||||
|
builder.addCase(mod.setter.pending, (state) => {
|
||||||
|
state.requestStatus[`save-${mod.key}`] = 'pending';
|
||||||
|
});
|
||||||
|
builder.addCase(mod.setter.fulfilled, (state, action) => {
|
||||||
|
state.requestStatus[`save-${mod.key}`] = 'success';
|
||||||
|
});
|
||||||
|
builder.addCase(mod.setter.rejected, (state) => {
|
||||||
|
state.requestStatus[`save-${mod.key}`] = 'error';
|
||||||
|
// TODO Report error
|
||||||
|
});
|
||||||
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
builder.addCase(mod.asyncSetter, mod.stateSetter);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
@ -154,6 +154,8 @@ export enum ConnectionStatus {
|
||||||
Connected,
|
Connected,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RequestStatus = 'pending' | 'success' | 'error';
|
||||||
|
|
||||||
export interface APIState {
|
export interface APIState {
|
||||||
client: KilovoltWS;
|
client: KilovoltWS;
|
||||||
connectionStatus: ConnectionStatus;
|
connectionStatus: ConnectionStatus;
|
||||||
|
@ -177,4 +179,5 @@ export interface APIState {
|
||||||
stulbeConfig: StulbeConfig;
|
stulbeConfig: StulbeConfig;
|
||||||
loyaltyConfig: LoyaltyConfig;
|
loyaltyConfig: LoyaltyConfig;
|
||||||
};
|
};
|
||||||
|
requestStatus: Record<string, RequestStatus>;
|
||||||
}
|
}
|
||||||
|
|
23
frontend/src/style.css
Normal file
23
frontend/src/style.css
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
@import '@radix-ui/colors/grayDark.css';
|
||||||
|
@import '@radix-ui/colors/tealDark.css';
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--gray1);
|
||||||
|
color: var(--teal12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', 'system-ui';
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (font-variation-settings: normal) {
|
||||||
|
html {
|
||||||
|
font-family: 'Inter var', 'system-ui';
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,173 @@
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import {
|
||||||
|
ChatBubbleIcon,
|
||||||
|
DashboardIcon,
|
||||||
|
FrameIcon,
|
||||||
|
GearIcon,
|
||||||
|
Link2Icon,
|
||||||
|
MixerHorizontalIcon,
|
||||||
|
StarIcon,
|
||||||
|
TableIcon,
|
||||||
|
TimerIcon,
|
||||||
|
} from '@radix-ui/react-icons';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { RootState } from '../store';
|
||||||
|
import { createWSClient } from '../store/api/reducer';
|
||||||
|
import { ConnectionStatus } from '../store/api/types';
|
||||||
|
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';
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
const LoadingDiv = styled('div', {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Spinner = styled('img', {
|
||||||
|
maxWidth: '100px',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingDiv>
|
||||||
|
<Spinner src={spinner} alt="Loading..." />
|
||||||
|
</LoadingDiv>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthDialog() {
|
||||||
|
const AuthWrapper = styled('div', {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <AuthWrapper></AuthWrapper>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: RouteSection[] = [
|
||||||
|
{
|
||||||
|
title: 'menu.sections.monitor',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'menu.pages.monitor.dashboard',
|
||||||
|
url: '/',
|
||||||
|
icon: <DashboardIcon />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.sections.strimertul',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'menu.pages.strimertul.settings',
|
||||||
|
url: '/http',
|
||||||
|
icon: <GearIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.pages.strimertul.stulbe',
|
||||||
|
url: '/stulbe',
|
||||||
|
icon: <Link2Icon />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.sections.twitch',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'menu.pages.twitch.configuration',
|
||||||
|
url: '/twitch/settings',
|
||||||
|
icon: <MixerHorizontalIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.pages.twitch.bot-commands',
|
||||||
|
url: '/twitch/bot/commands',
|
||||||
|
icon: <ChatBubbleIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.pages.twitch.bot-timers',
|
||||||
|
url: '/twitch/bot/timers',
|
||||||
|
icon: <TimerIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.pages.twitch.bot-alerts',
|
||||||
|
url: '/twitch/bot/alerts',
|
||||||
|
icon: <FrameIcon />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.sections.loyalty',
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: 'menu.pages.loyalty.configuration',
|
||||||
|
url: '/loyalty/settings',
|
||||||
|
icon: <MixerHorizontalIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.pages.loyalty.points',
|
||||||
|
url: '/loyalty/users',
|
||||||
|
icon: <TableIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'menu.pages.loyalty.rewards',
|
||||||
|
url: '/loyalty/rewards',
|
||||||
|
icon: <StarIcon />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
return <main></main>;
|
const client = useSelector((state: RootState) => state.api.client);
|
||||||
|
const connected = useSelector(
|
||||||
|
(state: RootState) => state.api.connectionStatus,
|
||||||
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client) {
|
||||||
|
dispatch(
|
||||||
|
createWSClient({
|
||||||
|
address:
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? 'ws://localhost:4337/ws'
|
||||||
|
: `ws://${window.location.host}/ws`,
|
||||||
|
password: localStorage.password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (connected === ConnectionStatus.NotConnected) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
||||||
|
return <AuthDialog />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled('main', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
minHeight: '100vh',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Sidebar sections={sections} />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/http" element={<ServerSettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
4
frontend/src/ui/brand.ts
Normal file
4
frontend/src/ui/brand.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export const APPNAME = 'strimertül';
|
||||||
|
export const APPREPO = 'strimertul/strimertul';
|
||||||
|
|
||||||
|
export default { APPNAME };
|
188
frontend/src/ui/components/Sidebar.tsx
Normal file
188
frontend/src/ui/components/Sidebar.tsx
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
import { styled } from '@stitches/react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
|
||||||
|
import { RootState } from '../../store';
|
||||||
|
import { APPNAME, APPREPO } from '../brand';
|
||||||
|
|
||||||
|
export interface RouteSection {
|
||||||
|
title: string;
|
||||||
|
links: Route[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Route {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon?: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
sections: RouteSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled('section', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: '100vh',
|
||||||
|
maxWidth: '220px',
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRight: '1px solid $gray6',
|
||||||
|
});
|
||||||
|
|
||||||
|
const Header = styled('div', {
|
||||||
|
padding: '0.8rem 1rem 1rem 0.8rem',
|
||||||
|
});
|
||||||
|
|
||||||
|
const AppName = styled('h1', {
|
||||||
|
fontSize: '1.4rem',
|
||||||
|
margin: '0.5rem 0 0.2rem 0',
|
||||||
|
});
|
||||||
|
|
||||||
|
const VersionLabel = styled('div', {
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '$teal8',
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateButton = styled('a', {
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '$yellow12',
|
||||||
|
border: '1px solid $yellow7',
|
||||||
|
padding: '0.2rem 0.4rem',
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
backgroundColor: '$yellow5',
|
||||||
|
borderRadius: '0.2rem',
|
||||||
|
display: 'inline-block',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '$yellow6',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const MenuSection = styled('article', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '0.2rem 0 0.5rem 0',
|
||||||
|
});
|
||||||
|
const MenuHeader = styled('header', {
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
padding: '0.5rem 0 0.5rem 0.8rem',
|
||||||
|
color: '$teal9',
|
||||||
|
});
|
||||||
|
const MenuLink = styled(Link, {
|
||||||
|
color: '$teal13',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
textDecoration: 'none',
|
||||||
|
gap: '0.6rem',
|
||||||
|
padding: '0.6rem 1.6rem 0.6rem 1rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: '300',
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
selected: {
|
||||||
|
color: '$teal13',
|
||||||
|
backgroundColor: '$teal5',
|
||||||
|
},
|
||||||
|
clickable: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '$teal4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function SidebarLink({ route: { title, url, icon } }: { route: Route }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const resolved = useResolvedPath(url);
|
||||||
|
const match = useMatch({ path: resolved.pathname, end: true });
|
||||||
|
return (
|
||||||
|
<MenuLink
|
||||||
|
status={match ? 'selected' : 'clickable'}
|
||||||
|
to={url.replace(/\/\//gi, '/')}
|
||||||
|
key={`${title}-${url}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{t(title)}
|
||||||
|
</MenuLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({
|
||||||
|
sections,
|
||||||
|
}: SidebarProps): React.ReactElement {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const client = useSelector((state: RootState) => state.api.client);
|
||||||
|
const [version, setVersion] = useState<string>(null);
|
||||||
|
const [lastVersion, setLastVersion] =
|
||||||
|
useState<{ url: string; name: string }>(null);
|
||||||
|
const dev = version && version.startsWith('v0.0.0');
|
||||||
|
|
||||||
|
async function fetchVersion() {
|
||||||
|
const versionString = await client.getKey('stul-meta/version');
|
||||||
|
setVersion(versionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLastVersion() {
|
||||||
|
try {
|
||||||
|
const req = await fetch(
|
||||||
|
`https://api.github.com/repos/${APPREPO}/releases/latest`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/vnd.github.v3+json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = await req.json();
|
||||||
|
setLastVersion({
|
||||||
|
url: data.html_url,
|
||||||
|
name: data.name,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// TODO Report error nicely
|
||||||
|
console.warn('Failed checking upstream for latest version', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLastVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (client) {
|
||||||
|
fetchVersion();
|
||||||
|
}
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<AppName>{APPNAME}</AppName>
|
||||||
|
|
||||||
|
<VersionLabel>
|
||||||
|
{version && !dev ? version : t('debug.dev-build')}
|
||||||
|
</VersionLabel>
|
||||||
|
{!dev && lastVersion && !version.startsWith(lastVersion.name) && (
|
||||||
|
<UpdateButton href={lastVersion.url}>UPDATE AVAILABLE</UpdateButton>
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
{sections.map(({ title: sectionTitle, links }) => (
|
||||||
|
<MenuSection key={sectionTitle}>
|
||||||
|
<MenuHeader>{t(sectionTitle)}</MenuHeader>
|
||||||
|
{links.map((route) => (
|
||||||
|
<SidebarLink route={route} key={`${route.title}-${route.url}`} />
|
||||||
|
))}
|
||||||
|
</MenuSection>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
5
frontend/src/ui/pages/Dashboard.tsx
Normal file
5
frontend/src/ui/pages/Dashboard.tsx
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Dashboard(): React.ReactElement {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
102
frontend/src/ui/pages/ServerSettings.tsx
Normal file
102
frontend/src/ui/pages/ServerSettings.tsx
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { useModule } from '../../lib/react-utils';
|
||||||
|
import apiReducer, { modules } from '../../store/api/reducer';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Field,
|
||||||
|
FieldNote,
|
||||||
|
InputBox,
|
||||||
|
Label,
|
||||||
|
PageContainer,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from '../theme';
|
||||||
|
|
||||||
|
export default function ServerSettingsPage(): React.ReactElement {
|
||||||
|
const [serverConfig, setServerConfig, loadStatus] = useModule(
|
||||||
|
modules.httpConfig,
|
||||||
|
);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const busy = loadStatus.load !== 'success' || loadStatus.save === '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))}
|
||||||
|
>
|
||||||
|
{t('form-actions.save')}
|
||||||
|
</Button>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
90
frontend/src/ui/theme.ts
Normal file
90
frontend/src/ui/theme.ts
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import { grayDark, tealDark, yellowDark } from '@radix-ui/colors';
|
||||||
|
import { createStitches } from '@stitches/react';
|
||||||
|
import * as UnstyledLabel from '@radix-ui/react-label';
|
||||||
|
|
||||||
|
export const { styled, theme } = createStitches({
|
||||||
|
theme: {
|
||||||
|
colors: {
|
||||||
|
...grayDark,
|
||||||
|
...tealDark,
|
||||||
|
...yellowDark,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PageContainer = styled('div', {
|
||||||
|
padding: '2rem',
|
||||||
|
maxWidth: '1000px',
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PageHeader = styled('header', {
|
||||||
|
marginBottom: '3rem',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PageTitle = styled('h1', {
|
||||||
|
fontSize: '25pt',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Field = styled('div', {
|
||||||
|
marginBottom: '2rem',
|
||||||
|
display: 'flex',
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
fullWidth: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.5rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FieldNote = styled('small', {
|
||||||
|
display: 'block',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '0 0.2rem',
|
||||||
|
fontWeight: '300',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Label = styled(UnstyledLabel.Root, {
|
||||||
|
userSelect: 'none',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InputBox = styled('input', {
|
||||||
|
all: 'unset',
|
||||||
|
fontWeight: '300',
|
||||||
|
border: '1px solid $gray6',
|
||||||
|
padding: '0.5rem',
|
||||||
|
borderRadius: '0.3rem',
|
||||||
|
backgroundColor: '$gray2',
|
||||||
|
'&:hover': {
|
||||||
|
borderColor: '$teal7',
|
||||||
|
},
|
||||||
|
'&:focus': {
|
||||||
|
borderColor: '$teal7',
|
||||||
|
backgroundColor: '$gray3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Button = styled('button', {
|
||||||
|
all: 'unset',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: '300',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.3rem',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
border: '1px solid $teal6',
|
||||||
|
backgroundColor: '$teal4',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: '$teal5',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
background: '$teal6',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default { styled, theme };
|
Loading…
Reference in a new issue