mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-18 01:50:50 +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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="main" class="is-fullheight"></div>
|
||||
<body class="dark-theme">
|
||||
<div id="main"></div>
|
||||
<script src="./src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</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",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@stitches/react": "^1.2.6",
|
||||
"@strimertul/kilovolt-client": "^6.2.0",
|
||||
"@types/node": "^15.0.2",
|
||||
"@types/reach__router": "^1.3.7",
|
||||
"@types/react": "^17.0.5",
|
||||
"@types/react-dom": "^17.0.4",
|
||||
"@vitejs/plugin-react": "^1.0.9",
|
||||
"i18next": "^20.6.1",
|
||||
"inter-ui": "^3.19.3",
|
||||
"normalize.css": "^8.0.1",
|
||||
"postcss-import": "^14.0.2",
|
||||
"pretty-ms": "^7.0.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^11.12.0",
|
||||
"react-redux": "^7.2.4",
|
||||
"react-router-dom": "^6.1.1",
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"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 ReactDOM from 'react-dom';
|
||||
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 './style.css';
|
||||
|
||||
import store from './store';
|
||||
import App from './ui/App';
|
||||
|
||||
// @ts-expect-error idk
|
||||
const history = createHistory(window);
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<LocationProvider history={history}>
|
||||
<BrowserRouter basename="/ui">
|
||||
<App />
|
||||
</LocationProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>,
|
||||
document.getElementById('main'),
|
||||
);
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '@strimertul/kilovolt-client';
|
||||
import { RootState } from '../store';
|
||||
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>({
|
||||
key,
|
||||
|
@ -23,10 +23,20 @@ export function useModule<T>({
|
|||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
setter: AsyncThunk<KilovoltMessage, T, {}>;
|
||||
asyncSetter: ActionCreatorWithOptionalPayload<T, string>;
|
||||
}): [
|
||||
T,
|
||||
// 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 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();
|
||||
useEffect(() => {
|
||||
dispatch(getter());
|
||||
|
@ -38,7 +48,14 @@ export function useModule<T>({
|
|||
client.unsubscribeKey(key, subscriber);
|
||||
};
|
||||
}, []);
|
||||
return [data, setter];
|
||||
return [
|
||||
data,
|
||||
setter,
|
||||
{
|
||||
load: loadStatus,
|
||||
save: saveStatus,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
loyaltyConfig: null,
|
||||
},
|
||||
requestStatus: {},
|
||||
};
|
||||
|
||||
const apiReducer = createSlice({
|
||||
|
@ -292,7 +293,27 @@ const apiReducer = createSlice({
|
|||
state.loyalty.users = payload;
|
||||
});
|
||||
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);
|
||||
});
|
||||
},
|
||||
|
|
|
@ -154,6 +154,8 @@ export enum ConnectionStatus {
|
|||
Connected,
|
||||
}
|
||||
|
||||
export type RequestStatus = 'pending' | 'success' | 'error';
|
||||
|
||||
export interface APIState {
|
||||
client: KilovoltWS;
|
||||
connectionStatus: ConnectionStatus;
|
||||
|
@ -177,4 +179,5 @@ export interface APIState {
|
|||
stulbeConfig: StulbeConfig;
|
||||
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 {
|
||||
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