1
0
Fork 0
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:
Ash Keel 2021-12-15 10:28:17 +01:00
parent b8465ceb38
commit de86fc1af6
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
16 changed files with 834 additions and 4229 deletions

View file

@ -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>

File diff suppressed because it is too large Load diff

View file

@ -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",

View 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

View file

@ -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'),
);

View file

@ -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 {

View file

@ -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"
}
}

View file

@ -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);
});
},

View file

@ -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
View 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';
}
}

View file

@ -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
View file

@ -0,0 +1,4 @@
export const APPNAME = 'strimertül';
export const APPREPO = 'strimertul/strimertul';
export default { APPNAME };

View 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>
);
}

View file

@ -0,0 +1,5 @@
import React from 'react';
export default function Dashboard(): React.ReactElement {
return <div></div>;
}

View 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
View 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 };