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",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"@stitches/react": "^1.2.8",
|
||||
"@strimertul/kilovolt-client": "^7.0.0",
|
||||
"@strimertul/kilovolt-client": "^7.0.1",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
|
@ -1509,9 +1509,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@strimertul/kilovolt-client": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.0.tgz",
|
||||
"integrity": "sha512-SD3mfxAnZcMXMud8tGQKbvkrRQqtM6RrZ68vMyjmuMAnnKTa+xUu5GKDgrFGjoqsrS6YN64dhAofTjELHb/a+g=="
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.1.tgz",
|
||||
"integrity": "sha512-G9xLgufk1aRmrzUnxZeQHbSLewEy199WAox4zGolWf9GdlKREKcfcLYWq/idxvTacRusRuex2Z7+Gl/lXh7Swg=="
|
||||
},
|
||||
"node_modules/@types/hoist-non-react-statics": {
|
||||
"version": "3.3.1",
|
||||
|
@ -6101,9 +6101,9 @@
|
|||
"requires": {}
|
||||
},
|
||||
"@strimertul/kilovolt-client": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.0.tgz",
|
||||
"integrity": "sha512-SD3mfxAnZcMXMud8tGQKbvkrRQqtM6RrZ68vMyjmuMAnnKTa+xUu5GKDgrFGjoqsrS6YN64dhAofTjELHb/a+g=="
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@strimertul/kilovolt-client/-/kilovolt-client-7.0.1.tgz",
|
||||
"integrity": "sha512-G9xLgufk1aRmrzUnxZeQHbSLewEy199WAox4zGolWf9GdlKREKcfcLYWq/idxvTacRusRuex2Z7+Gl/lXh7Swg=="
|
||||
},
|
||||
"@types/hoist-non-react-statics": {
|
||||
"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 type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
||||
|
||||
interface HTTPConfig {
|
||||
export interface HTTPConfig {
|
||||
bind: string;
|
||||
enable_static_server: boolean;
|
||||
kv_password: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface TwitchConfig {
|
||||
export interface TwitchConfig {
|
||||
enabled: boolean;
|
||||
enable_bot: boolean;
|
||||
api_client_id: string;
|
||||
api_client_secret: string;
|
||||
}
|
||||
|
||||
interface TwitchBotConfig {
|
||||
export interface TwitchBotConfig {
|
||||
username: string;
|
||||
oauth: string;
|
||||
channel: string;
|
||||
|
@ -32,7 +32,7 @@ export const accessLevels = [
|
|||
'streamer',
|
||||
] as const;
|
||||
|
||||
export type AccessLevelType = typeof accessLevels[number];
|
||||
export type AccessLevelType = (typeof accessLevels)[number];
|
||||
|
||||
export interface TwitchBotCustomCommand {
|
||||
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 loggingReducer from './logging/reducer';
|
||||
import extensionsReducer from './extensions/reducer';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
api: apiReducer.reducer,
|
||||
logging: loggingReducer.reducer,
|
||||
extensions: extensionsReducer.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
|
|
|
@ -25,8 +25,7 @@ import { useAppDispatch, useAppSelector } from '~/store';
|
|||
import { createWSClient, useAuthBypass } from '~/store/api/reducer';
|
||||
import { ConnectionStatus } from '~/store/api/types';
|
||||
import loggingReducer from '~/store/logging/reducer';
|
||||
// @ts-expect-error Asset import
|
||||
import spinner from '~/assets/icon-loading.svg';
|
||||
import { initializeExtensions } from '~/store/extensions/reducer';
|
||||
|
||||
import LogViewer from './components/LogViewer';
|
||||
import Sidebar, { RouteSection } from './components/Sidebar';
|
||||
|
@ -46,32 +45,9 @@ import StrimertulPage from './pages/Strimertul';
|
|||
import TwitchSettingsPage from './pages/TwitchSettings';
|
||||
import UISettingsPage from './pages/UISettingsPage';
|
||||
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[] = [
|
||||
{
|
||||
title: 'menu.sections.monitor',
|
||||
|
@ -220,9 +196,16 @@ export default function App(): JSX.Element {
|
|||
}
|
||||
if (!client) {
|
||||
void connectToKV();
|
||||
return;
|
||||
}
|
||||
if (connected === ConnectionStatus.AuthenticationNeeded) {
|
||||
// If Kilovolt is protected by password (pretty much always) use the bypass
|
||||
void dispatch(useAuthBypass());
|
||||
return;
|
||||
}
|
||||
if (connected === ConnectionStatus.Connected) {
|
||||
// Once connected, initialize UI subsystems
|
||||
void dispatch(initializeExtensions());
|
||||
}
|
||||
}, [ready, connected]);
|
||||
|
||||
|
@ -237,7 +220,7 @@ export default function App(): JSX.Element {
|
|||
}, [ready, uiConfig]);
|
||||
|
||||
if (connected === ConnectionStatus.NotConnected) {
|
||||
return <Loading message={t('special.loading')} />;
|
||||
return <Loading size="fullscreen" message={t('special.loading')} />;
|
||||
}
|
||||
|
||||
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 { 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 {
|
||||
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 (
|
||||
<PageContainer>
|
||||
<PageHeader>
|
||||
<PageTitle>{t('pages.extensions.title')}</PageTitle>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "react-jsx",
|
||||
|
|
Loading…
Reference in a new issue