mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-18 01:50:50 +00:00
feat: extension subsystem (working wip)
This commit is contained in:
parent
3cfcb326fc
commit
c20728b268
14 changed files with 491 additions and 61 deletions
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
|
@ -25,7 +25,7 @@
|
||||||
"@redux-devtools/extension": "^3.2.5",
|
"@redux-devtools/extension": "^3.2.5",
|
||||||
"@reduxjs/toolkit": "^1.9.1",
|
"@reduxjs/toolkit": "^1.9.1",
|
||||||
"@stitches/react": "^1.2.8",
|
"@stitches/react": "^1.2.8",
|
||||||
"@strimertul/kilovolt-client": "^7.0.0",
|
"@strimertul/kilovolt-client": "^7.0.1",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
"@types/react": "^18.0.27",
|
"@types/react": "^18.0.27",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
|
@ -1509,9 +1509,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@strimertul/kilovolt-client": {
|
"node_modules/@strimertul/kilovolt-client": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.1.tgz",
|
||||||
"integrity": "sha512-SD3mfxAnZcMXMud8tGQKbvkrRQqtM6RrZ68vMyjmuMAnnKTa+xUu5GKDgrFGjoqsrS6YN64dhAofTjELHb/a+g=="
|
"integrity": "sha512-G9xLgufk1aRmrzUnxZeQHbSLewEy199WAox4zGolWf9GdlKREKcfcLYWq/idxvTacRusRuex2Z7+Gl/lXh7Swg=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/hoist-non-react-statics": {
|
"node_modules/@types/hoist-non-react-statics": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
|
@ -6101,9 +6101,9 @@
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
"@strimertul/kilovolt-client": {
|
"@strimertul/kilovolt-client": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.1.tgz",
|
||||||
"integrity": "sha512-SD3mfxAnZcMXMud8tGQKbvkrRQqtM6RrZ68vMyjmuMAnnKTa+xUu5GKDgrFGjoqsrS6YN64dhAofTjELHb/a+g=="
|
"integrity": "sha512-G9xLgufk1aRmrzUnxZeQHbSLewEy199WAox4zGolWf9GdlKREKcfcLYWq/idxvTacRusRuex2Z7+Gl/lXh7Swg=="
|
||||||
},
|
},
|
||||||
"@types/hoist-non-react-statics": {
|
"@types/hoist-non-react-statics": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
685c0cbe0bfefb0bb19e2506207200a6
|
66a652086f52f01615c5cbe3a6b5474e
|
|
@ -1,19 +0,0 @@
|
||||||
export class Extension extends EventTarget {
|
|
||||||
private readonly worker: Worker;
|
|
||||||
|
|
||||||
constructor(public readonly source: string) {
|
|
||||||
super();
|
|
||||||
const blob = new Blob([source], { type: 'text/javascript' });
|
|
||||||
this.worker = new Worker(URL.createObjectURL(blob));
|
|
||||||
this.worker.onerror = (ev) =>
|
|
||||||
this.dispatchEvent(new CustomEvent('error', { detail: ev }));
|
|
||||||
this.worker.onmessage = (ev) =>
|
|
||||||
this.dispatchEvent(new CustomEvent('message', { detail: ev }));
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.worker.terminate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { Extension };
|
|
97
frontend/src/lib/extensions/extension.ts
Normal file
97
frontend/src/lib/extensions/extension.ts
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import {
|
||||||
|
ExtensionStatus,
|
||||||
|
ExtensionOptions,
|
||||||
|
ExtensionDependencies,
|
||||||
|
ExtensionHostMessage,
|
||||||
|
ExtensionHostCommand,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export class Extension extends EventTarget {
|
||||||
|
private readonly worker: Worker;
|
||||||
|
|
||||||
|
private workerStatus = ExtensionStatus.GettingReady;
|
||||||
|
|
||||||
|
private workerError?: ErrorEvent;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public readonly name: string,
|
||||||
|
public readonly source: string,
|
||||||
|
options: ExtensionOptions,
|
||||||
|
dependencies: ExtensionDependencies,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.worker = new Worker(
|
||||||
|
new URL('./workers/extensionHost.ts', import.meta.url),
|
||||||
|
{ type: 'module' },
|
||||||
|
);
|
||||||
|
this.worker.onerror = (ev) => {
|
||||||
|
this.workerError = ev;
|
||||||
|
this.dispatchEvent(new CustomEvent('error', { detail: ev }));
|
||||||
|
};
|
||||||
|
this.worker.onmessage = (ev: MessageEvent<ExtensionHostMessage>) =>
|
||||||
|
this.messageReceived(ev);
|
||||||
|
|
||||||
|
// Initialize ext host
|
||||||
|
this.send({
|
||||||
|
kind: 'arguments',
|
||||||
|
source,
|
||||||
|
options,
|
||||||
|
dependencies,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(cmd: ExtensionHostCommand) {
|
||||||
|
console.log(cmd);
|
||||||
|
this.worker.postMessage(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private messageReceived(ev: MessageEvent<ExtensionHostMessage>) {
|
||||||
|
const msg = ev.data;
|
||||||
|
switch (msg.kind) {
|
||||||
|
case 'status-change':
|
||||||
|
this.workerStatus = msg.status;
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('statusChanged', { detail: msg.status }),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get status() {
|
||||||
|
return this.workerStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get error() {
|
||||||
|
return this.workerError;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
switch (this.status) {
|
||||||
|
case ExtensionStatus.Ready:
|
||||||
|
return this.send({
|
||||||
|
kind: 'start',
|
||||||
|
});
|
||||||
|
case ExtensionStatus.GettingReady:
|
||||||
|
case ExtensionStatus.Error:
|
||||||
|
throw new Error('extension is not ready');
|
||||||
|
case ExtensionStatus.Running:
|
||||||
|
case ExtensionStatus.Finished:
|
||||||
|
throw new Error('extension is already running');
|
||||||
|
case ExtensionStatus.Terminated:
|
||||||
|
throw new Error(
|
||||||
|
'extension has been terminated, did you forget to trash this instance?',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.worker.terminate();
|
||||||
|
this.workerStatus = ExtensionStatus.Terminated;
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent('statusChanged', { detail: this.workerStatus }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { Extension };
|
35
frontend/src/lib/extensions/types.ts
Normal file
35
frontend/src/lib/extensions/types.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
export interface ExtensionDependencies {
|
||||||
|
kilovolt: {
|
||||||
|
address: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionOptions {
|
||||||
|
autostart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ExtensionStatus {
|
||||||
|
GettingReady = 'not-ready',
|
||||||
|
Ready = 'ready',
|
||||||
|
Running = 'running',
|
||||||
|
Finished = 'main-loop-finished',
|
||||||
|
Error = 'error',
|
||||||
|
Terminated = 'terminated',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExtensionHostCommand = EHParamMessage | EHStartMessage;
|
||||||
|
export type ExtensionHostMessage = EHStatusChangeMessage;
|
||||||
|
interface EHParamMessage {
|
||||||
|
kind: 'arguments';
|
||||||
|
options: ExtensionOptions;
|
||||||
|
dependencies: ExtensionDependencies;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
interface EHStartMessage {
|
||||||
|
kind: 'start';
|
||||||
|
}
|
||||||
|
interface EHStatusChangeMessage {
|
||||||
|
kind: 'status-change';
|
||||||
|
status: ExtensionStatus;
|
||||||
|
}
|
61
frontend/src/lib/extensions/workers/extensionHost.ts
Normal file
61
frontend/src/lib/extensions/workers/extensionHost.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import Kilovolt from '@strimertul/kilovolt-client';
|
||||||
|
import {
|
||||||
|
ExtensionHostCommand,
|
||||||
|
ExtensionHostMessage,
|
||||||
|
ExtensionStatus,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const sendMessage = (
|
||||||
|
message: ExtensionHostMessage,
|
||||||
|
transfer?: Transferable[],
|
||||||
|
) => postMessage(message, transfer);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function
|
||||||
|
async function ExtensionFunction(kv: Kilovolt) {}
|
||||||
|
|
||||||
|
let extFn: typeof ExtensionFunction = null;
|
||||||
|
let kv: Kilovolt;
|
||||||
|
|
||||||
|
let extensionStatus = ExtensionStatus.GettingReady;
|
||||||
|
function setStatus(status: ExtensionStatus) {
|
||||||
|
extensionStatus = status;
|
||||||
|
sendMessage({
|
||||||
|
kind: 'status-change',
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!extFn || !kv || extensionStatus !== ExtensionStatus.Ready)
|
||||||
|
throw new Error('extension not ready');
|
||||||
|
void extFn(kv).then(() => {
|
||||||
|
setStatus(ExtensionStatus.Finished);
|
||||||
|
});
|
||||||
|
setStatus(ExtensionStatus.Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
onmessage = async (ev: MessageEvent<ExtensionHostCommand>) => {
|
||||||
|
const cmd = ev.data;
|
||||||
|
switch (cmd.kind) {
|
||||||
|
case 'arguments': {
|
||||||
|
// Create Kilovolt instance
|
||||||
|
kv = new Kilovolt(
|
||||||
|
cmd.dependencies.kilovolt.address,
|
||||||
|
cmd.dependencies.kilovolt.password,
|
||||||
|
);
|
||||||
|
await kv.wait();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
extFn = ExtensionFunction.constructor('kv', cmd.source);
|
||||||
|
setStatus(ExtensionStatus.Ready);
|
||||||
|
|
||||||
|
if (cmd.options.autostart) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'start':
|
||||||
|
start();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
6
frontend/src/lib/extensions/workers/tsconfig.json
Normal file
6
frontend/src/lib/extensions/workers/tsconfig.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends":"../../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2019", "WebWorker"],
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,21 +3,21 @@
|
||||||
import KilovoltWS from '@strimertul/kilovolt-client';
|
import KilovoltWS from '@strimertul/kilovolt-client';
|
||||||
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
||||||
|
|
||||||
interface HTTPConfig {
|
export interface HTTPConfig {
|
||||||
bind: string;
|
bind: string;
|
||||||
enable_static_server: boolean;
|
enable_static_server: boolean;
|
||||||
kv_password: string;
|
kv_password: string;
|
||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TwitchConfig {
|
export interface TwitchConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
enable_bot: boolean;
|
enable_bot: boolean;
|
||||||
api_client_id: string;
|
api_client_id: string;
|
||||||
api_client_secret: string;
|
api_client_secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TwitchBotConfig {
|
export interface TwitchBotConfig {
|
||||||
username: string;
|
username: string;
|
||||||
oauth: string;
|
oauth: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
|
@ -32,7 +32,7 @@ export const accessLevels = [
|
||||||
'streamer',
|
'streamer',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type AccessLevelType = typeof accessLevels[number];
|
export type AccessLevelType = (typeof accessLevels)[number];
|
||||||
|
|
||||||
export interface TwitchBotCustomCommand {
|
export interface TwitchBotCustomCommand {
|
||||||
description: string;
|
description: string;
|
||||||
|
|
73
frontend/src/store/extensions/reducer.ts
Normal file
73
frontend/src/store/extensions/reducer.ts
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { Extension } from '~/lib/extensions/extension';
|
||||||
|
import {
|
||||||
|
ExtensionDependencies,
|
||||||
|
ExtensionOptions,
|
||||||
|
} from '~/lib/extensions/types';
|
||||||
|
import { RootState } from '..';
|
||||||
|
import { HTTPConfig } from '../api/types';
|
||||||
|
|
||||||
|
interface ExtensionsState {
|
||||||
|
ready: boolean;
|
||||||
|
installed: Record<string, Extension>;
|
||||||
|
dependencies: ExtensionDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionEntry {
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
options: ExtensionOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ExtensionsState = {
|
||||||
|
ready: false,
|
||||||
|
installed: {},
|
||||||
|
dependencies: {
|
||||||
|
kilovolt: { address: '' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const extensionsReducer = createSlice({
|
||||||
|
name: 'extensions',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
initialized(state, { payload }: PayloadAction<ExtensionDependencies>) {
|
||||||
|
state.dependencies = payload;
|
||||||
|
state.ready = true;
|
||||||
|
},
|
||||||
|
extensionAdded(state, { payload }: PayloadAction<ExtensionEntry>) {
|
||||||
|
state.installed[payload.name] = new Extension(
|
||||||
|
payload.name,
|
||||||
|
payload.source,
|
||||||
|
payload.options,
|
||||||
|
{
|
||||||
|
kilovolt: { ...state.dependencies.kilovolt },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const initializeExtensions = createAsyncThunk(
|
||||||
|
'extensions/initialize',
|
||||||
|
async (_: void, { getState, dispatch }) => {
|
||||||
|
// Get kv client
|
||||||
|
const { api } = getState() as RootState;
|
||||||
|
|
||||||
|
// Get kilovolt endpoint/credentials
|
||||||
|
const httpConfig = await api.client.getJSON<HTTPConfig>('http/config');
|
||||||
|
|
||||||
|
// Set dependencies
|
||||||
|
dispatch(
|
||||||
|
extensionsReducer.actions.initialized({
|
||||||
|
kilovolt: {
|
||||||
|
address: `ws://${httpConfig.bind}/ws`,
|
||||||
|
password: httpConfig.kv_password,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default extensionsReducer;
|
|
@ -4,11 +4,13 @@ import thunkMiddleware from 'redux-thunk';
|
||||||
|
|
||||||
import apiReducer from './api/reducer';
|
import apiReducer from './api/reducer';
|
||||||
import loggingReducer from './logging/reducer';
|
import loggingReducer from './logging/reducer';
|
||||||
|
import extensionsReducer from './extensions/reducer';
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
api: apiReducer.reducer,
|
api: apiReducer.reducer,
|
||||||
logging: loggingReducer.reducer,
|
logging: loggingReducer.reducer,
|
||||||
|
extensions: extensionsReducer.reducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
|
|
|
@ -25,8 +25,7 @@ import { useAppDispatch, useAppSelector } from '~/store';
|
||||||
import { createWSClient, useAuthBypass } from '~/store/api/reducer';
|
import { createWSClient, useAuthBypass } from '~/store/api/reducer';
|
||||||
import { ConnectionStatus } from '~/store/api/types';
|
import { ConnectionStatus } from '~/store/api/types';
|
||||||
import loggingReducer from '~/store/logging/reducer';
|
import loggingReducer from '~/store/logging/reducer';
|
||||||
// @ts-expect-error Asset import
|
import { initializeExtensions } from '~/store/extensions/reducer';
|
||||||
import spinner from '~/assets/icon-loading.svg';
|
|
||||||
|
|
||||||
import LogViewer from './components/LogViewer';
|
import LogViewer from './components/LogViewer';
|
||||||
import Sidebar, { RouteSection } from './components/Sidebar';
|
import Sidebar, { RouteSection } from './components/Sidebar';
|
||||||
|
@ -46,32 +45,9 @@ import StrimertulPage from './pages/Strimertul';
|
||||||
import TwitchSettingsPage from './pages/TwitchSettings';
|
import TwitchSettingsPage from './pages/TwitchSettings';
|
||||||
import UISettingsPage from './pages/UISettingsPage';
|
import UISettingsPage from './pages/UISettingsPage';
|
||||||
import ExtensionsPage from './pages/Extensions';
|
import ExtensionsPage from './pages/Extensions';
|
||||||
import { styled, TextBlock } from './theme';
|
import { styled } from './theme';
|
||||||
|
import Loading from './components/Loading';
|
||||||
|
|
||||||
const LoadingDiv = styled('div', {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '100vh',
|
|
||||||
});
|
|
||||||
|
|
||||||
const Spinner = styled('img', {
|
|
||||||
maxWidth: '100px',
|
|
||||||
});
|
|
||||||
|
|
||||||
interface LoadingProps {
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Loading({ message }: React.PropsWithChildren<LoadingProps>) {
|
|
||||||
return (
|
|
||||||
<LoadingDiv>
|
|
||||||
<Spinner src={spinner as string} alt="Loading..." />
|
|
||||||
<TextBlock>{message}</TextBlock>
|
|
||||||
</LoadingDiv>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const sections: RouteSection[] = [
|
const sections: RouteSection[] = [
|
||||||
{
|
{
|
||||||
title: 'menu.sections.monitor',
|
title: 'menu.sections.monitor',
|
||||||
|
@ -220,9 +196,16 @@ export default function App(): JSX.Element {
|
||||||
}
|
}
|
||||||
if (!client) {
|
if (!client) {
|
||||||
void connectToKV();
|
void connectToKV();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
||||||
|
// If Kilovolt is protected by password (pretty much always) use the bypass
|
||||||
void dispatch(useAuthBypass());
|
void dispatch(useAuthBypass());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (connected === ConnectionStatus.Connected) {
|
||||||
|
// Once connected, initialize UI subsystems
|
||||||
|
void dispatch(initializeExtensions());
|
||||||
}
|
}
|
||||||
}, [ready, connected]);
|
}, [ready, connected]);
|
||||||
|
|
||||||
|
@ -237,7 +220,7 @@ export default function App(): JSX.Element {
|
||||||
}, [ready, uiConfig]);
|
}, [ready, uiConfig]);
|
||||||
|
|
||||||
if (connected === ConnectionStatus.NotConnected) {
|
if (connected === ConnectionStatus.NotConnected) {
|
||||||
return <Loading message={t('special.loading')} />;
|
return <Loading size="fullscreen" message={t('special.loading')} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
||||||
|
|
46
frontend/src/ui/components/Loading.tsx
Normal file
46
frontend/src/ui/components/Loading.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// @ts-expect-error Asset import
|
||||||
|
import spinner from '~/assets/icon-loading.svg';
|
||||||
|
|
||||||
|
import { styled, TextBlock } from '../theme';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
size: {
|
||||||
|
fullscreen: {
|
||||||
|
minHeight: '100vh',
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
flex: '1',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LoadingDiv = styled('div', {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
variants,
|
||||||
|
});
|
||||||
|
|
||||||
|
const Spinner = styled('img', {
|
||||||
|
maxWidth: '100px',
|
||||||
|
});
|
||||||
|
interface LoadingProps {
|
||||||
|
size?: keyof typeof variants.size;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Loading({
|
||||||
|
message,
|
||||||
|
size,
|
||||||
|
}: React.PropsWithChildren<LoadingProps>) {
|
||||||
|
return (
|
||||||
|
<LoadingDiv size={size}>
|
||||||
|
<Spinner src={spinner as string} alt="Loading..." />
|
||||||
|
<TextBlock>{message}</TextBlock>
|
||||||
|
</LoadingDiv>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,15 +1,160 @@
|
||||||
import React from 'react';
|
import Editor from '@monaco-editor/react';
|
||||||
|
import { PlusIcon } from '@radix-ui/react-icons';
|
||||||
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageContainer, PageHeader, PageTitle } from '../theme';
|
import { useAppDispatch, useAppSelector } from '~/store';
|
||||||
|
import extensionsReducer, { ExtensionEntry } from '~/store/extensions/reducer';
|
||||||
|
import DialogContent from '../components/DialogContent';
|
||||||
|
import Loading from '../components/Loading';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
Field,
|
||||||
|
FlexRow,
|
||||||
|
InputBox,
|
||||||
|
Label,
|
||||||
|
PageContainer,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
} from '../theme';
|
||||||
|
|
||||||
export default function ExtensionsPage(): React.ReactElement {
|
export default function ExtensionsPage(): React.ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const extensions = useAppSelector((state) => state.extensions);
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [currentExtension, setCurrentExtension] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
new: boolean;
|
||||||
|
entry: ExtensionEntry;
|
||||||
|
}>({ open: false, new: false, entry: null });
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const filterLC = filter.toLowerCase();
|
||||||
|
|
||||||
|
if (!extensions.ready) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader>
|
||||||
|
<PageTitle>{t('pages.extensions.title')}</PageTitle>
|
||||||
|
</PageHeader>
|
||||||
|
<Loading
|
||||||
|
size="fill"
|
||||||
|
message={'one second, the extension subsystem is still not ready'}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>{t('pages.extensions.title')}</PageTitle>
|
<PageTitle>{t('pages.extensions.title')}</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
<div>
|
||||||
|
<Field size="fullWidth" spacing="none">
|
||||||
|
<FlexRow css={{ flex: 1, alignItems: 'stretch' }} spacing="1">
|
||||||
|
<Button
|
||||||
|
variation="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentExtension({
|
||||||
|
open: true,
|
||||||
|
new: true,
|
||||||
|
entry: {
|
||||||
|
name: '',
|
||||||
|
source: '',
|
||||||
|
options: {
|
||||||
|
autostart: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon /> new extension
|
||||||
|
</Button>
|
||||||
|
<InputBox
|
||||||
|
css={{ flex: 1 }}
|
||||||
|
placeholder="search by name"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FlexRow>
|
||||||
|
</Field>
|
||||||
|
{Object.values(extensions.installed)
|
||||||
|
?.filter((r) => r.name.toLowerCase().includes(filterLC))
|
||||||
|
.map((e) => (
|
||||||
|
<div key={e.name}>{e.name}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={currentExtension.open}
|
||||||
|
onOpenChange={(state) =>
|
||||||
|
setCurrentExtension({ ...currentExtension, open: state })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
title={currentExtension.new ? 'new extension' : 'edit extension'}
|
||||||
|
closeButton={true}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if ((e.target as HTMLFormElement).checkValidity()) {
|
||||||
|
dispatch(
|
||||||
|
extensionsReducer.actions.extensionAdded(
|
||||||
|
currentExtension.entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setCurrentExtension({ ...currentExtension, open: false });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field size="fullWidth" spacing="narrow">
|
||||||
|
<Label htmlFor="d-name">name</Label>
|
||||||
|
<InputBox
|
||||||
|
disabled={!currentExtension.new}
|
||||||
|
id="d-name"
|
||||||
|
value={currentExtension.entry?.name ?? ''}
|
||||||
|
required={true}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCurrentExtension({
|
||||||
|
...currentExtension,
|
||||||
|
entry: { ...currentExtension.entry, name: e.target.value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field size="fullWidth" spacing="narrow">
|
||||||
|
<Label>source</Label>
|
||||||
|
<Editor
|
||||||
|
height="50vh"
|
||||||
|
defaultLanguage="javascript"
|
||||||
|
defaultValue="// some comment"
|
||||||
|
theme="vs-dark"
|
||||||
|
value={currentExtension.entry?.source}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCurrentExtension({
|
||||||
|
...currentExtension,
|
||||||
|
entry: { ...currentExtension.entry, source: e },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variation="primary" type="submit">
|
||||||
|
{t('form-actions.save')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentExtension({ ...currentExtension, open: false })
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{t('form-actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
|
"module": "es2022",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
Loading…
Reference in a new issue