diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 00000000..f0509a51 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,10 @@ +# Android release +```bash +npx cap sync +# Build .aab file (will fail signing, use the next command to fix) +npx cap build android --keystorepath "" --keystorepass "" --keystorealias "" --keystorealiaspass "" --androidreleasetype AAB +# Sign the .aab +jarsigner -verbose -keystore "" "" "" +``` + +Then, `mobile/android/app/build/outputs/bundle/release/app-released.aab` is a signed .aab ready for publishing. \ No newline at end of file diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 73b79861..4b0a6b90 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -1,10 +1,10 @@ apply plugin: 'com.android.application' android { - namespace "com.pulse.app" + namespace "com.pulse_editor.app" compileSdk rootProject.ext.compileSdkVersion defaultConfig { - applicationId "com.pulse.app" + applicationId "com.pulse_editor.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 diff --git a/mobile/android/app/src/main/java/com/pulse/app/MainActivity.java b/mobile/android/app/src/main/java/com/pulse/app/MainActivity.java index 74dd37d8..2c8df85c 100644 --- a/mobile/android/app/src/main/java/com/pulse/app/MainActivity.java +++ b/mobile/android/app/src/main/java/com/pulse/app/MainActivity.java @@ -1,4 +1,4 @@ -package com.pulse.app; +package com.pulse_editor.app; import com.getcapacitor.BridgeActivity; diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml index c1c7cfd3..3c1ecbb6 100644 --- a/mobile/android/app/src/main/res/values/strings.xml +++ b/mobile/android/app/src/main/res/values/strings.xml @@ -2,6 +2,6 @@ Pulse Editor Pulse Editor - com.pulse.app - com.pulse.app + com.pulse_editor.app + com.pulse_editor.app diff --git a/mobile/capacitor.config.ts b/mobile/capacitor.config.ts index 9110b9d4..2943e447 100644 --- a/mobile/capacitor.config.ts +++ b/mobile/capacitor.config.ts @@ -1,7 +1,7 @@ import type { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { - appId: "com.pulse.app", + appId: "com.pulse_editor.app", appName: "Pulse Editor", webDir: "../build/next", android: { @@ -18,7 +18,7 @@ const config: CapacitorConfig = { enabled: true, }, CapacitorHttp: { - enabled: true, + enabled: false, }, }, }; diff --git a/npm-packages/cli/package.json b/npm-packages/cli/package.json index dc20f3bb..65df7d08 100644 --- a/npm-packages/cli/package.json +++ b/npm-packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@pulse-editor/cli", - "version": "0.1.0-beta.8", + "version": "0.1.1-beta.0", "license": "MIT", "bin": { "pulse": "dist/cli.js" diff --git a/package-lock.json b/package-lock.json index 2ade7164..6f614b68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2301,6 +2301,45 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", @@ -8343,11 +8382,11 @@ } }, "node_modules/@tailwindcss/oxide/node_modules/tar": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.1.tgz", - "integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==", + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", @@ -16991,9 +17030,9 @@ } }, "node_modules/next-auth": { - "version": "4.24.11", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", - "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", "license": "ISC", "dependencies": { "@babel/runtime": "^7.20.13", @@ -17007,9 +17046,9 @@ "uuid": "^8.3.2" }, "peerDependencies": { - "@auth/core": "0.34.2", - "next": "^12.2.5 || ^13 || ^14 || ^15", - "nodemailer": "^6.6.5", + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, @@ -26301,6 +26340,7 @@ "@capacitor/screen-orientation": "^7.0.2", "@capacitor/status-bar": "^7.0.3", "@capawesome/capacitor-file-picker": "^7.2.0", + "@dnd-kit/core": "^6.3.1", "@heroui/react": "^2.8.5", "@heroui/theme": "^2.4.23", "@langchain/anthropic": "^0.3.25", diff --git a/web/app/(main-layout)/layout.tsx b/web/app/(main-layout)/layout.tsx index f0c5e57f..b1750d29 100644 --- a/web/app/(main-layout)/layout.tsx +++ b/web/app/(main-layout)/layout.tsx @@ -1,11 +1,13 @@ import Nav from "@/components/interface/navigation/nav"; import CapacitorProvider from "@/components/providers/capacitor-provider"; +import DndProvider from "@/components/providers/dnd-provider"; import EditorContextProvider from "@/components/providers/editor-context-provider"; import InterModuleCommunicationProvider from "@/components/providers/imc-provider"; import PlatformAssistantProvider from "@/components/providers/platform-assistant-provider"; import RemoteModuleProvider from "@/components/providers/remote-module-provider"; import WrappedHeroUIProvider from "@/components/providers/wrapped-hero-ui-provider"; import { Analytics } from "@vercel/analytics/next"; +import { ReactFlowProvider } from "@xyflow/react"; import "material-icons/iconfont/material-icons.css"; import type { Metadata } from "next"; import { Suspense } from "react"; @@ -31,14 +33,18 @@ export default function RootLayout({ - - - - + + + + + + + + diff --git a/web/components/app-loaders/sandbox-app-loader.tsx b/web/components/app-loaders/sandbox-app-loader.tsx index 2176cea9..ae54683a 100644 --- a/web/components/app-loaders/sandbox-app-loader.tsx +++ b/web/components/app-loaders/sandbox-app-loader.tsx @@ -2,11 +2,11 @@ import BaseAppLoader from "@/components/app-loaders/base-app-loader"; import Loading from "@/components/interface/status-screens/loading"; import { EditorContext } from "@/components/providers/editor-context-provider"; import { IMCContext } from "@/components/providers/imc-provider"; -import { DragEventTypeEnum, PlatformEnum } from "@/lib/enums"; +import { PlatformEnum } from "@/lib/enums"; import { usePlatformApi } from "@/lib/hooks/use-platform-api"; import { getPlatform } from "@/lib/platform-api/platform-checker"; -import { ExtensionApp, FileDragData } from "@/lib/types"; -import { addToast } from "@heroui/react"; +import { ExtensionApp } from "@/lib/types"; +import { useDroppable } from "@dnd-kit/core"; import { ConnectionListener, IMCMessage, @@ -32,6 +32,16 @@ export default function SandboxAppLoader({ const editorContext = useContext(EditorContext); const imcContext = useContext(IMCContext); + const { resolvedTheme } = useTheme(); + const { platformApi } = usePlatformApi(); + + const { setNodeRef } = useDroppable({ + id: "app-node-view-" + viewModel.viewId, + data: { + viewId: viewModel.viewId, + }, + }); + const [currentExtension, setCurrentExtension] = useState< ExtensionApp | undefined >(); @@ -48,9 +58,6 @@ export default function SandboxAppLoader({ // const [isConnected, setIsConnected] = useState(false); const [isInitialized, setIsInitialized] = useState(false); - const { resolvedTheme } = useTheme(); - const { platformApi } = usePlatformApi(); - // Update view Id when the view model changes useEffect(() => { // If the view Id changes (e.g. when switching file but not extension), @@ -137,13 +144,6 @@ export default function SandboxAppLoader({ }; }, []); - useEffect(() => { - console.log( - "Is dragging over canvas: ", - editorContext?.editorStates.isDraggingOverCanvas, - ); - }, [editorContext?.editorStates.isDraggingOverCanvas]); - // Set is loading extension to true when current extension changes useEffect(() => { if (currentExtension) { @@ -316,48 +316,7 @@ export default function SandboxAppLoader({ return (
{ - e.stopPropagation(); - const types = e.dataTransfer.types; - if ( - types.includes(`application/${DragEventTypeEnum.File.toLowerCase()}`) - ) { - e.preventDefault(); // allow drop - e.dataTransfer.dropEffect = "move"; - } else { - e.dataTransfer.dropEffect = "none"; - } - }} - onDrop={async (e) => { - const dataText = e.dataTransfer.getData( - `application/${DragEventTypeEnum.File.toLowerCase()}`, - ); - if (!dataText) { - return; - } - console.log("Dropped item:", dataText); - try { - const data = JSON.parse(dataText) as FileDragData; - - e.preventDefault(); - const uri = data.uri; - - // Send uri to app view - await imcContext?.polyIMC?.sendMessage( - viewModel.viewId, - IMCMessageTypeEnum.EditorAppReceiveFileUri, - { - uri, - }, - ); - } catch (error) { - addToast({ - title: "Failed to open file", - description: "The dropped file data is invalid.", - color: "danger", - }); - } - }} + ref={setNodeRef} > {isLookingForExtension ? (
diff --git a/web/components/explorer/app/app-explorer.tsx b/web/components/explorer/app/app-explorer.tsx index 118333bc..e7197830 100644 --- a/web/components/explorer/app/app-explorer.tsx +++ b/web/components/explorer/app/app-explorer.tsx @@ -1,44 +1,86 @@ import AppPreviewCard from "@/components/marketplace/app/app-preview-card"; +import { DraggableItem } from "@/components/misc/draggable-item"; import { EditorContext } from "@/components/providers/editor-context-provider"; -import { DragEventTypeEnum } from "@/lib/enums"; import { useScreenSize } from "@/lib/hooks/use-screen-size"; import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; -import { AppDragData, AppViewConfig } from "@/lib/types"; +import { + AppDragData, + AppViewConfig, + DragData, + ExtensionApp, +} from "@/lib/types"; +import { useDraggable } from "@dnd-kit/core"; import { Button } from "@heroui/react"; -import { useContext } from "react"; +import { useContext, useEffect, useState } from "react"; import { v4 } from "uuid"; export default function AppExplorer() { const editorContext = useContext(EditorContext); + const extensions = editorContext?.persistSettings?.extensions ?? []; + + const previews = extensions.map((ext, index) => ( + + )); + + return ( +
+

Tap or drag an extension to open it.

+ +
+ {previews} +
+
+ +
+
+ ); +} + +function DraggableAppPreviewCard({ ext }: { ext: ExtensionApp }) { + const editorContext = useContext(EditorContext); + const { createAppViewInCanvasView } = useTabViewManager(); const { isLandscape } = useScreenSize(); + const { setNodeRef, listeners, isDragging } = useDraggable({ + id: `draggable-app-${ext.config.id}`, + data: { + type: "app", + data: { + app: ext, + } as AppDragData, + } as DragData, + }); - const extensions = editorContext?.persistSettings?.extensions ?? []; + const [isDragFinished, setIsDragFinished] = useState(false); - const previews = extensions.map((ext, index) => ( -
{ - editorContext?.setEditorStates((prev) => ({ - ...prev, - isDraggingOverCanvas: true, - })); - e.dataTransfer.setData( - `application/${DragEventTypeEnum.App.toLowerCase()}`, - JSON.stringify({ - app: ext, - } as AppDragData), - ); - }} - onDragEnd={() => { - editorContext?.setEditorStates((prev) => ({ - ...prev, - isDraggingOverCanvas: false, - })); - }} + useEffect(() => { + if (isDragging) { + setIsDragFinished(false); + } else { + // Delay 200ms and then set isDragFinished to true, + // so the app preview button is not triggered via a press event. + setTimeout(() => { + setIsDragFinished(true); + }, 200); + } + }, [isDragging]); + + return ( + { const config: AppViewConfig = { app: ext.config.id, @@ -62,30 +106,8 @@ export default function AppExplorer() { })); } }} + listeners={listeners} /> -
- )); - - return ( -
-

Tap or drag an extension to open it.

- -
- {previews} -
-
- -
-
+ ); } diff --git a/web/components/explorer/file-system/tree-view.tsx b/web/components/explorer/file-system/tree-view.tsx index 551789cf..efa35fdb 100644 --- a/web/components/explorer/file-system/tree-view.tsx +++ b/web/components/explorer/file-system/tree-view.tsx @@ -3,17 +3,19 @@ import ContextMenu from "@/components/interface/context-menu"; import Icon from "@/components/misc/icon"; import { EditorContext } from "@/components/providers/editor-context-provider"; -import { DragEventTypeEnum, PlatformEnum } from "@/lib/enums"; +import { PlatformEnum } from "@/lib/enums"; import { useWorkspace } from "@/lib/hooks/use-workspace"; import { AbstractPlatformAPI } from "@/lib/platform-api/abstract-platform-api"; import { getPlatform } from "@/lib/platform-api/platform-checker"; import { ContextMenuState, + DragData, FileDragData, FileSystemObject, TreeViewGroupRef, TreeViewNodeRef, } from "@/lib/types"; +import { useDraggable } from "@dnd-kit/core"; import { Button, Input } from "@heroui/react"; import { forwardRef, @@ -55,6 +57,15 @@ const TreeViewNode = forwardRef(function TreeViewNode( })); const { refreshWorkspaceContent } = useWorkspace(); + const { setNodeRef, listeners } = useDraggable({ + id: `draggable-file-${object.uri}`, + data: { + type: "file", + data: { + uri: object.uri, + } as FileDragData, + } as DragData, + }); const [isFolderCollapsed, setIsFolderCollapsed] = useState(true); const [isSelected, setIsSelected] = useState(false); @@ -155,26 +166,6 @@ const TreeViewNode = forwardRef(function TreeViewNode( }); } - const onDragStart = (e: React.DragEvent) => { - editorContext?.setEditorStates((prev) => ({ - ...prev, - isDraggingOverCanvas: true, - })); - e.dataTransfer.setData( - `application/${DragEventTypeEnum.File.toLowerCase()}`, - JSON.stringify({ - uri: object.uri, - } as FileDragData), - ); - }; - - const onDragEnd = () => { - editorContext?.setEditorStates((prev) => ({ - ...prev, - isDraggingOverCanvas: false, - })); - }; - return (
{isRenaming ? ( @@ -215,10 +206,9 @@ const TreeViewNode = forwardRef(function TreeViewNode( <> {object.isFolder ? ( - - ) : ( - isShowCompatibleChip && ( +
-

- This app's module federation version ({hostMFVersion}) - and library version ({hostLibVersion}) match the host - version. The app should work correctly. -

+ {!isMFCompatible && ( +

+ This app is outdated and no longer a valid module + federation app. Please update the app to the latest + version.
+ Host MF version: {hostMFVersion} +
+ App MF version: {remoteMFVersion} +

+ )} + {!isLibCompatible && ( +

+ This app's library version is outdated and may not + work correctly. Please update the app to the latest + version. +
+ Host lib version: {hostLibVersion} +
+ App lib version: {remoteLibVersion} +

+ )}
} > - +
+ ) : ( + isShowCompatibleChip && ( +
+ +

+ This app's module federation version ( + {hostMFVersion}) and library version ( + {hostLibVersion}) match the host version. The app + should work correctly. +

+
+ } + > + + +
) ))}
- ) : ( - - )} - - + setContextMenuState({ x: 0, y: 0, isOpen: false }); + }} + > +

Uninstall

+ + ) : ( + + )} + + + )}

{extension.config.displayName}

{extension.config.version}

diff --git a/web/components/misc/draggable-item.tsx b/web/components/misc/draggable-item.tsx new file mode 100644 index 00000000..298485eb --- /dev/null +++ b/web/components/misc/draggable-item.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { DraggableSyntheticListeners } from "@dnd-kit/core"; +import type { Transform } from "@dnd-kit/utilities"; +// import classNames from "classnames"; +import React, { forwardRef } from "react"; + +interface DraggableProps { + listeners?: DraggableSyntheticListeners; + transform?: Transform | null; + children?: React.ReactNode; + className?: string; +} + +export const DraggableItem = forwardRef( + function Draggable({ listeners, transform, className, children }, ref) { + return ( +
+
+ {children} +
+
+ ); + }, +); diff --git a/web/components/modals/publish-workflow-modal.tsx b/web/components/modals/publish-workflow-modal.tsx index 3064c195..51a6eabc 100644 --- a/web/components/modals/publish-workflow-modal.tsx +++ b/web/components/modals/publish-workflow-modal.tsx @@ -58,6 +58,9 @@ export default function PublishWorkflowModal({ await fetchAPI("/api/workflow/publish", { method: "POST", body: JSON.stringify({ ...workflow }), + headers: { + "Content-Type": "application/json", + }, }); addToast({ diff --git a/web/components/modals/sharing-modal.tsx b/web/components/modals/sharing-modal.tsx index 469e0d37..d696adea 100644 --- a/web/components/modals/sharing-modal.tsx +++ b/web/components/modals/sharing-modal.tsx @@ -73,6 +73,9 @@ export default function SharingModal({ visibility, name: app, }), + headers: { + "Content-Type": "application/json", + }, }); mutate(); diff --git a/web/components/providers/dnd-provider.tsx b/web/components/providers/dnd-provider.tsx new file mode 100644 index 00000000..bc34c70f --- /dev/null +++ b/web/components/providers/dnd-provider.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useScreenSize } from "@/lib/hooks/use-screen-size"; +import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; +import { getRemoteClientBaseURL } from "@/lib/module-federation/remote"; +import { + AppDragData, + AppNodeData, + AppViewConfig, + DragData, + ExtensionApp, + FileDragData, +} from "@/lib/types"; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + MouseSensor, + pointerWithin, + TouchSensor, + useDndContext, + useSensor, +} from "@dnd-kit/core"; +import { addToast } from "@heroui/react"; +import { IMCMessageTypeEnum, ViewModel } from "@pulse-editor/shared-utils"; +import { Node as ReactFlowNode, useReactFlow } from "@xyflow/react"; +import { ReactNode, useContext, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { v4 } from "uuid"; +import { DraggableItem } from "../misc/draggable-item"; +import Icon from "../misc/icon"; +import { EditorContext } from "./editor-context-provider"; +import { IMCContext } from "./imc-provider"; + +export default function DndProvider({ + children, +}: Readonly<{ children: ReactNode }>) { + const editorContext = useContext(EditorContext); + const imcContext = useContext(IMCContext); + + const [draggedData, setDraggedData] = useState( + undefined, + ); + + const mouseSensor = useSensor(MouseSensor, { + // Activation constraint for mouse: 5px movement + activationConstraint: { + distance: 5, + }, + }); + const touchSensor = useSensor(TouchSensor, { + // Press delay of 250ms, with tolerance of 5px of movement + activationConstraint: { + delay: 1000, + tolerance: 100, + }, + }); + const { isLandscape } = useScreenSize(); + const { createAppViewInCanvasView } = useTabViewManager(); + const { updateNodeData } = useReactFlow(); + + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + async function handleDragStart(event: DragStartEvent) { + const data = event.active.data.current as DragData | undefined; + setDraggedData(data); + editorContext?.setEditorStates((prev) => ({ + ...prev, + isDraggingOverCanvas: true, + isSideMenuOpen: isLandscape ? prev.isSideMenuOpen : false, + })); + } + + async function handleDragEnd(event: DragEndEvent) { + const { over } = event; + + if (over) { + // Handle drop logic here + if (draggedData?.type === "app") { + const appData = draggedData.data as AppDragData; + if (over.id.toString().startsWith("canvas-view-")) { + // Handle app creation drop + try { + const app: ExtensionApp = appData.app; + const config: AppViewConfig = { + app: app.config.id, + viewId: `${app.config.id}-${v4()}`, + recommendedHeight: app.config.recommendedHeight, + recommendedWidth: app.config.recommendedWidth, + }; + createAppViewInCanvasView(config); + } catch (error) { + addToast({ + title: "Failed to open app", + description: "The dropped app data is invalid.", + color: "danger", + }); + } + } else if (over.id.toString().startsWith("node-handle-input-")) { + // Handle app-instance drop + try { + const app: ExtensionApp = appData.app; + const config: AppViewConfig = { + app: app.config.id, + viewId: `${app.config.id}-${v4()}`, + recommendedHeight: app.config.recommendedHeight, + recommendedWidth: app.config.recommendedWidth, + }; + + const { viewId, node, paramName } = over.data.current as { + viewId: string; + node: ReactFlowNode; + paramName: string; + }; + + updateNodeData(viewId, { + ownedAppViews: { + ...node.data.ownedAppViews, + [paramName]: { + viewId: config.viewId, + appConfig: app.config, + }, + } as Record, + }); + } catch (error) { + addToast({ + title: "Failed to link app", + description: "The dropped app data is invalid.", + color: "danger", + }); + } + } else { + addToast({ + title: "Invalid drop target", + description: "You cannot drop the file here.", + color: "warning", + }); + } + } else if ( + draggedData?.type === "file" && + over.id.toString().startsWith("app-node-view-") + ) { + // Handle file drop into app + const fileData = draggedData.data as FileDragData; + + try { + const uri = fileData.uri; + + const { viewId } = over.data.current as { + viewId: string; + }; + + // Send uri to app view + await imcContext?.polyIMC?.sendMessage( + viewId, + IMCMessageTypeEnum.EditorAppReceiveFileUri, + { + uri, + }, + ); + } catch (error) { + addToast({ + title: "Failed to open file", + description: "The dropped file data is invalid.", + color: "danger", + }); + } + } else { + addToast({ + title: "Invalid drop target", + description: "Something went wrong when dropping the item.", + color: "danger", + }); + } + } else { + addToast({ + title: "Invalid drop target", + description: "You cannot drop the item here.", + color: "warning", + }); + } + + editorContext?.setEditorStates((prev) => ({ + ...prev, + isDraggingOverCanvas: false, + })); + + setDraggedData(undefined); + } + + return ( + + {children} + + {mounted && } + + ); +} + +function DraggableOverlay({ data }: { data: DragData | undefined }) { + const editorContext = useContext(EditorContext); + const { active, over } = useDndContext(); + + useEffect(() => { + const overId = over?.id.toString(); + + if (overId?.startsWith("canvas-view-") && data?.type === "app") { + editorContext?.setEditorStates((prev) => ({ + ...prev, + dropMessage: "Drop app here to add to current canvas", + })); + } else if ( + overId?.startsWith("node-handle-input-") && + data?.type === "app" + ) { + editorContext?.setEditorStates((prev) => ({ + ...prev, + dropMessage: "Drop app here to create app instance for node", + })); + } else if (overId?.startsWith("app-node-view-") && data?.type === "file") { + editorContext?.setEditorStates((prev) => ({ + ...prev, + dropMessage: "Drop file here to send to app", + })); + } else { + editorContext?.setEditorStates((prev) => ({ + ...prev, + dropMessage: undefined, + })); + } + }, [over]); + + // Don’t render portal until client-side + if (typeof document === "undefined") return null; + + return createPortal( + + {active ? ( + + + + ) : null} + , + document.body, + ); +} + +function DragPreview({ data }: { data: DragData | undefined }) { + if (data?.type === "app") { + const appData = data.data as AppDragData; + + return ( +
+ {appData.app.config.thumbnail && ( + {appData.app.config.thumbnail} + )} +
+ ); + } else if (data?.type === "file") { + return ( +
+ +
+ ); + } +} diff --git a/web/components/views/canvas/canvas-view.tsx b/web/components/views/canvas/canvas-view.tsx index 0c092d6a..03e94989 100644 --- a/web/components/views/canvas/canvas-view.tsx +++ b/web/components/views/canvas/canvas-view.tsx @@ -1,19 +1,17 @@ import PublishWorkflowModal from "@/components/modals/publish-workflow-modal"; import { EditorContext } from "@/components/providers/editor-context-provider"; -import { DragEventTypeEnum } from "@/lib/enums"; import { useRegisterMenuAction } from "@/lib/hooks/menu-actions/use-register-menu-action"; import { useAppInfo } from "@/lib/hooks/use-app-info"; import useCanvasWorkflow from "@/lib/hooks/use-canvas-workflow"; import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; import { - AppDragData, AppInfoModalContent, AppNodeData, AppViewConfig, CanvasViewConfig, - ExtensionApp, } from "@/lib/types"; -import { addToast, Button } from "@heroui/react"; +import { useDroppable } from "@dnd-kit/core"; +import { Button } from "@heroui/react"; import { addEdge, applyEdgeChanges, @@ -40,7 +38,6 @@ import { useRef, useState, } from "react"; -import { v4 } from "uuid"; import Icon from "../../misc/icon"; import AppNode from "./nodes/app-node/app-node"; import "./theme.css"; @@ -88,8 +85,7 @@ export default function CanvasView({ } = useCanvasWorkflow(config.initialWorkflowContent); const viewport = useViewport(); const { screenToFlowPosition } = useReactFlow(); - const { deleteAppViewInCanvasView, createAppViewInCanvasView } = - useTabViewManager(); + const { deleteAppViewInCanvasView } = useTabViewManager(); const [isPublishModalOpen, setIsPublishModalOpen] = useState(false); @@ -212,6 +208,14 @@ export default function CanvasView({ isActive, ); + const { setNodeRef, isOver, active } = useDroppable({ + id: "canvas-view-" + config.viewId, + }); + + useEffect(() => { + console.log("CanvasView active droppable:", active); + }, [active]); + // Promote nodes to workflow nodes, // or remove workflow nodes that are no longer in the config useEffect(() => { @@ -298,99 +302,68 @@ export default function CanvasView({ return (
{ - const types = e.dataTransfer.types; - if ( - types.includes(`application/${DragEventTypeEnum.App.toLowerCase()}`) - ) { - e.preventDefault(); // allow drop - e.dataTransfer.dropEffect = "copy"; - } else { - e.dataTransfer.dropEffect = "none"; - } - }} - onDrop={(e) => { - const dataText = e.dataTransfer.getData( - `application/${DragEventTypeEnum.App.toLowerCase()}`, - ); - if (!dataText) { - return; - } - console.log("Dropped item:", dataText); - try { - const data = JSON.parse(dataText) as AppDragData; - e.preventDefault(); - - const app: ExtensionApp = data.app; - const config: AppViewConfig = { - app: app.config.id, - viewId: `${app.config.id}-${v4()}`, - recommendedHeight: app.config.recommendedHeight, - recommendedWidth: app.config.recommendedWidth, - }; - createAppViewInCanvasView(config); - } catch (error) { - addToast({ - title: "Failed to open app", - description: "The dropped app data is invalid.", - color: "danger", - }); - } + className="bg-content3 text-content3-foreground h-full w-full" + style={{ + opacity: + isOver && active?.id.toString().startsWith("draggable-app-") + ? 0.5 + : 1, }} > - - - - +
+ + + + - + +
); } diff --git a/web/components/views/canvas/nodes/app-node/layout.tsx b/web/components/views/canvas/nodes/app-node/layout.tsx index 4057cabd..6fe4ad06 100644 --- a/web/components/views/canvas/nodes/app-node/layout.tsx +++ b/web/components/views/canvas/nodes/app-node/layout.tsx @@ -1,13 +1,8 @@ import Icon from "@/components/misc/icon"; import { EditorContext } from "@/components/providers/editor-context-provider"; import BaseAppView from "@/components/views/base/base-app-view"; -import { DragEventTypeEnum } from "@/lib/enums"; -import { - AppDragData, - AppNodeData, - AppViewConfig, - ExtensionApp, -} from "@/lib/types"; +import { AppNodeData, EditorContextType } from "@/lib/types"; +import { useDroppable } from "@dnd-kit/core"; import { addToast, Button, @@ -19,7 +14,7 @@ import { SelectItem, Tooltip, } from "@heroui/react"; -import { Action, ViewModel } from "@pulse-editor/shared-utils"; +import { Action, TypedVariable } from "@pulse-editor/shared-utils"; import { NodeResizeControl, NodeResizer, @@ -31,7 +26,6 @@ import { } from "@xyflow/react"; import clsx from "clsx"; import { useContext, useEffect, useState } from "react"; -import { v4 } from "uuid"; import NodeHandle from "./node-handle"; export default function CanvasNodeViewLayout({ @@ -79,12 +73,7 @@ export default function CanvasNodeViewLayout({ } return ( -
{ - e.stopPropagation(); - }} - > +
{/* Control */}
{isShowingWorkflowConnector && ( -
+
{Object.entries(selectedAction?.parameters ?? {}).map( ([paramName, param]) => ( -
{ - e.stopPropagation(); - if (param.type !== "app-instance") { - return; - } - const types = e.dataTransfer.types; - if ( - types.includes( - `application/${DragEventTypeEnum.App.toLowerCase()}`, - ) - ) { - e.preventDefault(); - e.dataTransfer.dropEffect = "link"; - } - }} - onDrop={async (e) => { - const dataText = e.dataTransfer.getData( - `application/${DragEventTypeEnum.App.toLowerCase()}`, - ); - if (!dataText) { - return; - } - console.log("Dropped item:", dataText); - try { - const data = JSON.parse(dataText) as AppDragData; - e.stopPropagation(); - e.preventDefault(); - - const app: ExtensionApp = data.app; - const config: AppViewConfig = { - app: app.config.id, - viewId: `${app.config.id}-${v4()}`, - recommendedHeight: app.config.recommendedHeight, - recommendedWidth: app.config.recommendedWidth, - }; - - updateNodeData(viewId, { - ownedAppViews: { - ...node.data.ownedAppViews, - [paramName]: { - viewId: config.viewId, - appConfig: app.config, - }, - } as Record, - }); - } catch (error) { - addToast({ - title: "Failed to link app", - description: "The dropped app data is invalid.", - color: "danger", - }); - } - }} - className="pointer-events-auto" - > - {param.type === "app-instance" ? ( - - ) : ( - - )} -
+ paramName={paramName} + param={param} + editorContext={editorContext} + node={node} + /> ), )}
@@ -224,7 +129,7 @@ export default function CanvasNodeViewLayout({ {/* Output Handles */}
{isShowingWorkflowConnector && ( -
+
{Object.entries(selectedAction?.returns ?? {}).map( ([key, param]) => ( ); } + +function InputHandle({ + paramName, + param, + editorContext, + node, +}: { + paramName: string; + param: TypedVariable; + editorContext: EditorContextType | undefined; + node: ReactFlowNode; +}) { + if (param.type === "app-instance") { + return ( +
+ +
+ ); + } else { + return ( + + ); + } +} + +function DroppableInputHandle({ + paramName, + node, + editorContext, +}: { + paramName: string; + node: ReactFlowNode; + editorContext: EditorContextType | undefined; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `node-handle-input-${node.id}-${paramName}`, + data: { + viewId: node.id, + node, + paramName, + }, + }); + + return ( + + ); +} diff --git a/web/components/views/view-area.tsx b/web/components/views/view-area.tsx index bf931de0..274a138b 100644 --- a/web/components/views/view-area.tsx +++ b/web/components/views/view-area.tsx @@ -1,7 +1,6 @@ import { useTabViewManager } from "@/lib/hooks/use-tab-view-manager"; import { AppViewConfig, CanvasViewConfig } from "@/lib/types"; import { ViewModeEnum } from "@pulse-editor/shared-utils"; -import { ReactFlowProvider } from "@xyflow/react"; import { useSearchParams } from "next/navigation"; import { useContext, useEffect, useRef, useState } from "react"; import { v4 } from "uuid"; @@ -81,23 +80,23 @@ export default function ViewArea() { }, [tabViews]); return ( -
+
{tabViews.length === 0 ? ( ) : tabIndex < 0 || tabIndex >= tabViews.length ? (
No view selected
) : (
{isShowTabs && (
-
+
) : tabView.type === ViewModeEnum.Canvas ? ( - - - + ) : (
Unknown view type
)}
))}
+ + {editorContext?.editorStates.dropMessage && ( +
+ {editorContext?.editorStates.dropMessage} +
+ )}
)}
diff --git a/web/lib/agent/agent-runner.ts b/web/lib/agent/agent-runner.ts index fea977c7..a35e7d36 100644 --- a/web/lib/agent/agent-runner.ts +++ b/web/lib/agent/agent-runner.ts @@ -38,6 +38,9 @@ export async function runAgentMethodCloud( { method: "POST", body: JSON.stringify({ prompt }), + headers: { + "Content-Type": "application/json", + }, }, ); diff --git a/web/lib/enums.ts b/web/lib/enums.ts index fa1f8ee4..b4e539b8 100644 --- a/web/lib/enums.ts +++ b/web/lib/enums.ts @@ -18,9 +18,4 @@ export enum SideMenuTabEnum { Apps = "Apps", Workspace = "Workspace", } - -export enum DragEventTypeEnum { - File = "File", - App = "App", -} // #endregion diff --git a/web/lib/hooks/use-auth.ts b/web/lib/hooks/use-auth.ts index 007d0af7..21b4f606 100644 --- a/web/lib/hooks/use-auth.ts +++ b/web/lib/hooks/use-auth.ts @@ -77,8 +77,7 @@ export function useAuth() { possibly after other hooks are fired. */ if (!token.value) { - // CapacitorCookies.clearAllCookies(); - CapacitorCookies.deleteCookie({ + await CapacitorCookies.deleteCookie({ key: "pulse-editor.session-token", url: process.env.NEXT_PUBLIC_BACKEND_URL, }); @@ -95,6 +94,17 @@ export function useAuth() { refreshSession(); }, [editorContext?.editorStates.isRefreshSession]); + // Set is login status in Preferences in Capacitor if logged in + useEffect(() => { + if (getPlatform() === PlatformEnum.Capacitor) { + const isLoggedIn = session !== undefined; + Preferences.set({ + key: "pulse-editor.is-logged-in", + value: isLoggedIn ? "true" : "false", + }); + } + }, [session]); + // Open a sign-in page if the user is not signed in. async function signIn() { if (session) { diff --git a/web/lib/modalities/image-gen/image-gen.ts b/web/lib/modalities/image-gen/image-gen.ts index 1cdfa25e..d77fc506 100644 --- a/web/lib/modalities/image-gen/image-gen.ts +++ b/web/lib/modalities/image-gen/image-gen.ts @@ -131,6 +131,9 @@ export function getImageGenModel( body: JSON.stringify({ token: apiKey, }), + headers: { + "Content-Type": "application/json", + }, }, ); console.log("Fetching prediction status:", prediction.id); diff --git a/web/lib/modalities/music-gen/music-gen.ts b/web/lib/modalities/music-gen/music-gen.ts index 15a98ea5..f0e30820 100644 --- a/web/lib/modalities/music-gen/music-gen.ts +++ b/web/lib/modalities/music-gen/music-gen.ts @@ -130,6 +130,9 @@ export function getMusicGenModel( body: JSON.stringify({ token: apiKey, }), + headers: { + "Content-Type": "application/json", + }, }, ); console.log("Fetching prediction status:", prediction.id); diff --git a/web/lib/modalities/video-gen/video-gen.ts b/web/lib/modalities/video-gen/video-gen.ts index e1e21c87..e42339e8 100644 --- a/web/lib/modalities/video-gen/video-gen.ts +++ b/web/lib/modalities/video-gen/video-gen.ts @@ -160,6 +160,9 @@ export function getVideoGenModel( body: JSON.stringify({ token: apiKey, }), + headers: { + "Content-Type": "application/json", + }, }, ); console.log("Fetching prediction status:", prediction.id); diff --git a/web/lib/pulse-editor-website/backend.ts b/web/lib/pulse-editor-website/backend.ts index 269f544d..faa00437 100644 --- a/web/lib/pulse-editor-website/backend.ts +++ b/web/lib/pulse-editor-website/backend.ts @@ -18,6 +18,22 @@ export async function fetchAPI( the cookies are set in the webview automatically. */ if (getPlatform() === PlatformEnum.Capacitor) { + // Fallback to web fetch if session is available for + // better performance and streaming support. + // However, do not use web fetch for session endpoint. + if (relativeUrl.toString() !== "/api/auth/session") { + const isLoggedInPref = await Preferences.get({ + key: "pulse-editor.is-logged-in", + }); + const hasSession = isLoggedInPref.value === "true"; + if (hasSession) { + return await fetch(url, { + credentials: "include", + ...options, + }); + } + } + // attach cookie manually const tokenPref = await Preferences.get({ key: "pulse-editor.session-token", @@ -32,7 +48,11 @@ export async function fetchAPI( if (token) { headers.append( "Cookie", - `pulse-editor.session-token=${token}; Path=/; Expires=${exp}; SameSite=None; Secure; ${process.env.NEXT_PUBLIC_BACKEND_URL ? "Domain=" + new URL(process.env.NEXT_PUBLIC_BACKEND_URL).hostname : ""}`, + `pulse-editor.session-token=${token}; Path=/; Expires=${exp}; SameSite=None; Secure; ${ + process.env.NEXT_PUBLIC_BACKEND_URL + ? "Domain=" + new URL(process.env.NEXT_PUBLIC_BACKEND_URL).hostname + : "" + }`, ); options = { ...options, @@ -47,23 +67,42 @@ export async function fetchAPI( method: options?.method ?? "GET", headers: headerObj, data: options?.body, + responseType: "text", // πŸ‘ˆ helps handle binary/stream responses }); - console.log( - `${url}. \n\nRequest header: ${JSON.stringify(headerObj)} \n\nNative response: ${JSON.stringify(nativeResponse)} \n\nCookie: ${document.cookie}`, - ); + let body: BodyInit | undefined; - const data = - typeof nativeResponse.data === "string" - ? nativeResponse.data - : JSON.stringify(nativeResponse.data); + if (nativeResponse.data instanceof Blob) { + // Handle binary/stream responses + body = nativeResponse.data; + } else if (typeof nativeResponse.data === "string") { + // Regular text response + body = nativeResponse.data; + } else if (nativeResponse.data && typeof nativeResponse.data === "object") { + // JSON response + body = JSON.stringify(nativeResponse.data); + } else if (nativeResponse.data?.stream) { + // πŸ‘‡ Handle custom stream-like responses + const reader = nativeResponse.data.stream.getReader(); + const stream = new ReadableStream({ + async pull(controller) { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + }); + body = stream; + } else { + body = undefined; + } - // Convert CapacitorHttpResponse to Fetch Response - const fetchResponse = new Response(data, { + const fetchResponse = new Response(body, { status: nativeResponse.status, headers: nativeResponse.headers, }); - return fetchResponse; } diff --git a/web/lib/types.ts b/web/lib/types.ts index f10599f5..5ab0bf94 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -97,6 +97,7 @@ export type EditorStates = { // Drag control isDraggingOverCanvas?: boolean; + dropMessage?: string; }; /** @@ -379,6 +380,11 @@ export type FileDragData = { export type AppDragData = { app: ExtensionApp; }; + +export type DragData = { + type: "file" | "app"; + data: FileDragData | AppDragData; +}; // #endregion // #region Action diff --git a/web/package.json b/web/package.json index 5b532dfb..876d6a0c 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "@capacitor/screen-orientation": "^7.0.2", "@capacitor/status-bar": "^7.0.3", "@capawesome/capacitor-file-picker": "^7.2.0", + "@dnd-kit/core": "^6.3.1", "@heroui/react": "^2.8.5", "@heroui/theme": "^2.4.23", "@langchain/anthropic": "^0.3.25", @@ -73,4 +74,4 @@ "typescript": "^5", "workbox-webpack-plugin": "^7.3.0" } -} \ No newline at end of file +}