diff --git a/packages/noodl-editor/src/editor/src/contexts/PluginContext/PluginContext.tsx b/packages/noodl-editor/src/editor/src/contexts/PluginContext/PluginContext.tsx new file mode 100644 index 0000000..525e25a --- /dev/null +++ b/packages/noodl-editor/src/editor/src/contexts/PluginContext/PluginContext.tsx @@ -0,0 +1,35 @@ +import React, { createContext, useContext, useEffect } from 'react'; + +import { Slot } from '@noodl-core-ui/types/global'; + +import { commandEventHandler } from './iframe'; + +export interface PluginContext {} + +const PluginContext = createContext({}); + +export interface PluginContextProps { + children: Slot; +} + +export function PluginContextProvider({ children }: PluginContextProps) { + // Listen for all the iframe commands + useEffect(() => { + window.addEventListener('message', commandEventHandler, false); + return function () { + window.removeEventListener('message', commandEventHandler, false); + }; + }, []); + + return {children}; +} + +export function usePluginContext() { + const context = useContext(PluginContext); + + if (context === undefined) { + throw new Error('usePluginContext must be a child of PluginContextProvider'); + } + + return context; +} diff --git a/packages/noodl-editor/src/editor/src/contexts/PluginContext/commands/notify.ts b/packages/noodl-editor/src/editor/src/contexts/PluginContext/commands/notify.ts new file mode 100644 index 0000000..f7edbe5 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/contexts/PluginContext/commands/notify.ts @@ -0,0 +1,24 @@ +import { ToastLayer } from '../../../views/ToastLayer'; + +export type Command = { + kind: 'notify'; + messageType: 'info' | 'success' | 'error'; + message: string; +}; + +export async function execute(command: Command, _event: MessageEvent) { + switch (command.messageType || 'info') { + case 'info': { + ToastLayer.showInteraction(String(command.message)); + break; + } + case 'success': { + ToastLayer.showSuccess(String(command.message)); + break; + } + case 'error': { + ToastLayer.showError(String(command.message)); + break; + } + } +} diff --git a/packages/noodl-editor/src/editor/src/contexts/PluginContext/commands/upload-aws-s3.ts b/packages/noodl-editor/src/editor/src/contexts/PluginContext/commands/upload-aws-s3.ts new file mode 100644 index 0000000..8ea95fe --- /dev/null +++ b/packages/noodl-editor/src/editor/src/contexts/PluginContext/commands/upload-aws-s3.ts @@ -0,0 +1,169 @@ +import s3 from 's3'; +import { filesystem, platform } from '@noodl/platform'; + +import { Environment, EnvironmentDataFormat } from '@noodl-models/CloudServices'; +import { ProjectModel } from '@noodl-models/projectmodel'; +import { createEditorCompilation } from '@noodl-utils/compilation/compilation.editor'; +import { guid } from '@noodl-utils/utils'; + +import { ToastLayer } from '../../../views/ToastLayer'; + +export type Command = { + kind: 'upload-aws-s3'; + taskId: string; + cloudService: EnvironmentDataFormat; + s3: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + region: string; + bucket: string; + prefix: string; + } + messages: { + progress: string; + success: string; + failure: string; + } + // Initial version to a more extendable system that can be used with these + // kind of events. + events: { + complete: { + kind: "fetch", + resource: string, + options: RequestInit + }[] + } +}; + +export async function execute(command: Command, event: MessageEvent) { + const project = ProjectModel.instance; + const taskId = command.taskId || guid(); + + // Setup the build steps + const compilation = createEditorCompilation(project).addProjectBuildScripts(); + + // Create a temp folder + const tempDir = platform.getTempPath() + '/upload-' + taskId; + + // Deploy to temp folder + await compilation.deployToFolder(tempDir, { + environment: command.cloudService ? new Environment(command.cloudService) : undefined + }); + + // Upload to S3 + try { + await uploadDirectory( + { + localDir: tempDir, + accessKeyId: command.s3.accessKeyId, + secretAccessKey: command.s3.secretAccessKey, + sessionToken: command.s3.sessionToken, + region: command.s3.region, + bucket: command.s3.bucket, + prefix: command.s3.prefix + }, + ({ progress, total }) => { + ToastLayer.showProgress(command.messages.progress, (progress / total) * 100, taskId); + } + ); + + ToastLayer.showSuccess(command.messages.success); + } catch (error) { + console.error(error); + ToastLayer.showError(command.messages.failure); + } finally { + ToastLayer.hideProgress(taskId); + } + + // Clean up the temp folder + filesystem.removeDirRecursive(tempDir); + + // NOTE: Would be nice to clean up the events in here, so "complete" is like + // "finally". And then also have "success" and "failure". + + // Notify that the process is done + event.source.postMessage({ + kind: "upload-aws-s3", + id: taskId, + status: "complete", + }, { targetOrigin: event.origin }) + + // Execute complete event + if (Array.isArray(command?.events?.complete)) { + for (const event of command.events.complete) { + if (event.kind === "fetch") { + await fetch(event.resource, event.options); + } else { + console.error("invalid event type", event); + } + } + } +} + +function uploadDirectory( + options: { + localDir: string; + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + region: string; + bucket: string; + prefix: string; + }, + progress: (args: { progress: number; total: number }) => void +) { + return new Promise((resolve, reject) => { + // https://github.com/noodlapp/node-s3-client?tab=readme-ov-file#create-a-client + const client = s3.createClient({ + maxAsyncS3: 20, // this is the default + s3RetryCount: 3, // this is the default + s3RetryDelay: 1000, // this is the default + multipartUploadThreshold: 20971520, // this is the default (20 MB) + multipartUploadSize: 15728640, // this is the default (15 MB) + s3Options: { + accessKeyId: options.accessKeyId, + secretAccessKey: options.secretAccessKey, + sessionToken: options.sessionToken, + region: options.region + } + }); + + function getFileCacheControl(fullPath: string) { + switch (fullPath.split('.').at(-1)) { + case 'html': + return 'no-cache'; + default: { + const TIME_1 = 31536000; // 365 days + const TIME_2 = 10800; // 3 hours for files with hash + const maxAge = /-[a-f0-9]{16}\./.test(fullPath) ? TIME_1 : TIME_2; + return `public, max-age=${maxAge}`; + } + } + } + + const uploader = client.uploadDir({ + localDir: options.localDir, + deleteRemoved: true, + s3Params: { + Bucket: options.bucket, + Prefix: options.prefix + }, + getS3Params(fullPath, _s3Object, callback) { + callback(null, { + CacheControl: getFileCacheControl(fullPath) + }); + } + }); + + uploader.on('error', function (error) { + reject(error); + }); + uploader.on('progress', function () { + progress({ progress: uploader.progressAmount, total: uploader.progressTotal }); + }); + uploader.on('end', function () { + resolve(); + }); + }); +} diff --git a/packages/noodl-editor/src/editor/src/contexts/PluginContext/iframe.ts b/packages/noodl-editor/src/editor/src/contexts/PluginContext/iframe.ts new file mode 100644 index 0000000..e1e80a6 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/contexts/PluginContext/iframe.ts @@ -0,0 +1,19 @@ +import * as Notify from './commands/notify'; +import * as UploadAwsS3 from './commands/upload-aws-s3'; + +type IFrameCommand = Notify.Command | UploadAwsS3.Command; + +const commands: Record Promise> = { + notify: Notify.execute, + 'upload-aws-s3': UploadAwsS3.execute +}; + +export function commandEventHandler(event: MessageEvent) { + try { + const command = event.data; + const handler = commands[command.kind]; + handler && handler(command, event); + } catch (error) { + console.error(error); + } +} diff --git a/packages/noodl-editor/src/editor/src/contexts/PluginContext/index.ts b/packages/noodl-editor/src/editor/src/contexts/PluginContext/index.ts new file mode 100644 index 0000000..ca9cdf0 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/contexts/PluginContext/index.ts @@ -0,0 +1 @@ +export * from './PluginContext'; diff --git a/packages/noodl-editor/src/editor/src/models/CloudServices/ExternalCloudService.ts b/packages/noodl-editor/src/editor/src/models/CloudServices/ExternalCloudService.ts index 2b7a81f..2c7f2f4 100644 --- a/packages/noodl-editor/src/editor/src/models/CloudServices/ExternalCloudService.ts +++ b/packages/noodl-editor/src/editor/src/models/CloudServices/ExternalCloudService.ts @@ -3,7 +3,7 @@ import { JSONStorage } from '@noodl/platform'; import { CreateEnvironment, CreateEnvironmentRequest, UpdateEnvironmentRequest } from '@noodl-models/CloudServices'; /** The data format is separated from our internal model. */ -type EnvironmentDataFormat = { +export type EnvironmentDataFormat = { enabled: boolean; id: string; name: string; diff --git a/packages/noodl-editor/src/editor/src/pages/EditorPage/EditorPage.tsx b/packages/noodl-editor/src/editor/src/pages/EditorPage/EditorPage.tsx index ea1d60e..0c04092 100644 --- a/packages/noodl-editor/src/editor/src/pages/EditorPage/EditorPage.tsx +++ b/packages/noodl-editor/src/editor/src/pages/EditorPage/EditorPage.tsx @@ -43,6 +43,7 @@ import { BaseWindow } from '../../views/windows/BaseWindow'; import { whatsnewRender } from '../../whats-new'; import { IRouteProps } from '../AppRoute'; import { useSetupSettings } from './useSetupSettings'; +import { PluginContextProvider } from '@noodl-contexts/PluginContext'; // eslint-disable-next-line @typescript-eslint/no-var-requires const ImportOverwritePopupTemplate = require('../../templates/importoverwritepopup.html'); @@ -223,24 +224,26 @@ export function EditorPage({ route }: EditorPageProps) { return ( - - {isLoading ? ( - - ) : ( - <> - } - second={{Boolean(Document) && }} - sizeMin={200} - size={frameDividerSize} - horizontal - onSizeChanged={setFrameDividerSize} - /> + + + {isLoading ? ( + + ) : ( + <> + } + second={{Boolean(Document) && }} + sizeMin={200} + size={frameDividerSize} + horizontal + onSizeChanged={setFrameDividerSize} + /> - {Boolean(lesson) && } - - )} - + {Boolean(lesson) && } + + )} + + ); diff --git a/packages/noodl-editor/src/editor/src/views/DeployPopup/DeployPopup.tsx b/packages/noodl-editor/src/editor/src/views/DeployPopup/DeployPopup.tsx index 85c1145..ee6eebd 100644 --- a/packages/noodl-editor/src/editor/src/views/DeployPopup/DeployPopup.tsx +++ b/packages/noodl-editor/src/editor/src/views/DeployPopup/DeployPopup.tsx @@ -1,4 +1,5 @@ -import React, { RefObject } from 'react'; +import { usePluginContext } from '@noodl-contexts/PluginContext'; +import React, { RefObject, useEffect, useRef } from 'react'; import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator'; import { BaseDialog } from '@noodl-core-ui/components/layout/BaseDialog'; @@ -25,7 +26,12 @@ function DeployPopupChild() { > - , testId: 'self-hosting-tab-button' }]} /> + , testId: 'fluxscape-tab-button' }, + { label: 'Self Hosting', content: , testId: 'self-hosting-tab-button' } + ]} + /> {hasActivity && } @@ -54,3 +60,8 @@ export function DeployPopup(props: DeployPopupProps) { ); } + +function FluxscapeDeployTab() { + // Preview URL: 'http://192.168.0.33:8574/' + return