Ahmad Kholid пре 2 година
родитељ
комит
58d6eaa57a
50 измењених фајлова са 3611 додато и 2050 уклоњено
  1. 2 0
      package.json
  2. 1 0
      postcss.config.js
  3. 38 0
      src/assets/css/flow.css
  4. 4 7
      src/components/block/BlockBase.vue
  5. 54 86
      src/components/block/BlockBasic.vue
  6. 81 22
      src/components/block/BlockBasicWithFallback.vue
  7. 46 107
      src/components/block/BlockConditions.vue
  8. 24 18
      src/components/block/BlockDelay.vue
  9. 25 21
      src/components/block/BlockElementExists.vue
  10. 0 64
      src/components/block/BlockExportData.vue
  11. 48 46
      src/components/block/BlockGroup.vue
  12. 18 8
      src/components/block/BlockLoopBreakpoint.vue
  13. 24 8
      src/components/block/BlockRepeatTask.vue
  14. 22 9
      src/components/block/BlockWebhook.vue
  15. 8 6
      src/components/newtab/app/AppSidebar.vue
  16. 100 0
      src/components/newtab/app/AppSurvey.vue
  17. 3 3
      src/components/newtab/workflow/WorkflowBuilder.vue
  18. 2 17
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  19. 32 160
      src/components/newtab/workflow/WorkflowEditBlock.vue
  20. 129 0
      src/components/newtab/workflow/WorkflowEditor.vue
  21. 5 5
      src/components/newtab/workflow/WorkflowSettings.vue
  22. 7 15
      src/components/newtab/workflow/WorkflowShare.vue
  23. 17 5
      src/components/newtab/workflow/edit/EditConditions.vue
  24. 432 0
      src/components/newtab/workflow/editor/EditorLocalActions.vue
  25. 53 0
      src/components/newtab/workflow/editor/EditorLogs.vue
  26. 39 35
      src/components/newtab/workflow/editor/EditorSearchBlocks.vue
  27. 70 0
      src/components/newtab/workflows/WorkflowsHosted.vue
  28. 393 0
      src/components/newtab/workflows/WorkflowsLocal.vue
  29. 44 0
      src/components/newtab/workflows/WorkflowsShared.vue
  30. 5 13
      src/components/ui/UiTable.vue
  31. 7 27
      src/composable/editorBlock.js
  32. 1 1
      src/composable/shortcut.js
  33. 27 0
      src/lib/pinia.js
  34. 32 176
      src/newtab/App.vue
  35. 3 0
      src/newtab/index.js
  36. 1 0
      src/newtab/pages/Logs.vue
  37. 59 443
      src/newtab/pages/Workflows.vue
  38. 941 0
      src/newtab/pages/workflows/[id].old.vue
  39. 247 705
      src/newtab/pages/workflows/[id].vue
  40. 1 31
      src/store/index.js
  41. 17 0
      src/stores/folder.js
  42. 27 0
      src/stores/main.js
  43. 43 0
      src/stores/user.js
  44. 184 0
      src/stores/workflow.js
  45. 165 0
      src/utils/EditorUtils.js
  46. 83 0
      src/utils/convertWorkflowData.js
  47. 12 5
      src/utils/dataMigration.js
  48. 19 1
      src/utils/helper.js
  49. 7 5
      src/utils/shared.js
  50. 9 1
      yarn.lock

+ 2 - 0
package.json

@@ -55,11 +55,13 @@
     "drawflow": "^0.0.58",
     "drawflow": "^0.0.58",
     "idb": "^7.0.0",
     "idb": "^7.0.0",
     "lodash.clonedeep": "^4.5.0",
     "lodash.clonedeep": "^4.5.0",
+    "lodash.merge": "^4.6.2",
     "mitt": "^3.0.0",
     "mitt": "^3.0.0",
     "mousetrap": "^1.6.5",
     "mousetrap": "^1.6.5",
     "nanoid": "^3.2.0",
     "nanoid": "^3.2.0",
     "object-path": "^0.11.8",
     "object-path": "^0.11.8",
     "papaparse": "^5.3.1",
     "papaparse": "^5.3.1",
+    "pinia": "^2.0.14",
     "rxjs": "^7.5.5",
     "rxjs": "^7.5.5",
     "tippy.js": "^6.3.1",
     "tippy.js": "^6.3.1",
     "v-remixicon": "^0.1.1",
     "v-remixicon": "^0.1.1",

+ 1 - 0
postcss.config.js

@@ -1,5 +1,6 @@
 module.exports = {
 module.exports = {
   plugins: {
   plugins: {
+    'tailwindcss/nesting': {},
     tailwindcss: {},
     tailwindcss: {},
     autoprefixer: {},
     autoprefixer: {},
   },
   },

+ 38 - 0
src/assets/css/flow.css

@@ -0,0 +1,38 @@
+.vue-flow__minimap {
+	@apply rounded-lg dark:bg-gray-800;
+}
+
+.vue-flow__node {
+	& > div {
+		@apply rounded-lg transition;
+	}
+	&.selected > div {
+		@apply ring-2 ring-accent;
+	}
+	&:hover,
+	&.selected {
+		.menu {
+			@apply translate-y-11;
+		}
+	}
+
+	.vue-flow__handle {
+		@apply h-4 w-4 rounded-full border-0;
+		&.target {
+			@apply bg-accent -ml-4;
+		}
+		&.source {
+			border-width: 3px;
+			@apply border-accent -mr-4 bg-white dark:bg-black;
+		}
+	}
+}
+
+.vue-flow__edge.selected .vue-flow__edge-path {
+	stroke: theme('colors.green.300');
+}
+
+.vue-flow__edge-path {
+  stroke: theme('colors.accent');
+  stroke-width: 3;
+}

+ 4 - 7
src/components/block/BlockBase.vue

@@ -1,12 +1,9 @@
 <template>
 <template>
-  <div class="block-base relative" @dblclick="$emit('edit')">
+  <div class="block-base relative w-48" @dblclick="$emit('edit')">
     <slot name="prepend" />
     <slot name="prepend" />
-    <div
-      :class="contentClass"
-      class="z-10 bg-white dark:bg-gray-800 relative rounded-lg overflow-hidden w-full p-4 block-base__content"
-    >
+    <ui-card :class="contentClass" class="z-10 relative block-base__content">
       <slot></slot>
       <slot></slot>
-    </div>
+    </ui-card>
     <slot name="append" />
     <slot name="append" />
     <div
     <div
       v-if="!minimap"
       v-if="!minimap"
@@ -22,7 +19,7 @@
           v-if="!hideDelete && !hideEdit"
           v-if="!hideDelete && !hideEdit"
           class="border-r border-gray-600 h-5 mx-3"
           class="border-r border-gray-600 h-5 mx-3"
         />
         />
-        <button v-if="!hideDelete" @click="$emit('delete')">
+        <button v-if="!hideDelete" @click.stop="$emit('delete')">
           <v-remixicon size="20" name="riDeleteBin7Line" />
           <v-remixicon size="20" name="riDeleteBin7Line" />
         </button>
         </button>
         <slot name="action" />
         <slot name="action" />

+ 54 - 86
src/components/block/BlockBasic.vue

@@ -3,42 +3,51 @@
     :id="componentId"
     :id="componentId"
     :hide-edit="block.details.disableEdit"
     :hide-edit="block.details.disableEdit"
     :hide-delete="block.details.disableDelete"
     :hide-delete="block.details.disableDelete"
-    :minimap="editor.minimap"
-    class="block-basic"
-    @edit="editBlock"
-    @delete="editor.removeNodeId(`node-${block.id}`)"
+    :data-position="JSON.stringify(position)"
+    class="block-basic group"
+    @edit="$emit('edit')"
+    @delete="$emit('delete', id)"
   >
   >
+    <Handle
+      v-if="label !== 'trigger'"
+      :id="`${id}-input-1`"
+      type="target"
+      :position="Position.Left"
+    />
     <div class="flex items-center">
     <div class="flex items-center">
       <span
       <span
-        :class="
-          block.data.disableBlock ? 'bg-box-transparent' : block.category.color
-        "
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block p-2 mr-2 rounded-lg dark:text-black"
         class="inline-block p-2 mr-2 rounded-lg dark:text-black"
       >
       >
         <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
         <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
       </span>
       </span>
-      <div style="max-width: 200px">
+      <div class="overflow-hidden flex-1">
         <p
         <p
           v-if="block.details.id"
           v-if="block.details.id"
-          class="font-semibold leading-none whitespace-nowrap"
+          class="font-semibold leading-tight text-overflow whitespace-nowrap"
         >
         >
           {{ t(`workflow.blocks.${block.details.id}.name`) }}
           {{ t(`workflow.blocks.${block.details.id}.name`) }}
         </p>
         </p>
         <p class="text-gray-600 dark:text-gray-200 text-overflow leading-tight">
         <p class="text-gray-600 dark:text-gray-200 text-overflow leading-tight">
-          {{ block.data.description }}
+          {{ data.description }}
         </p>
         </p>
-        <input
-          type="text"
-          class="hidden trigger"
-          disabled="true"
-          @change="handleDataChange"
-        />
       </div>
       </div>
     </div>
     </div>
+    <slot :block="block"></slot>
+    <template #prepend>
+      <div
+        v-if="block.details.id !== 'trigger'"
+        :title="t('workflow.blocks.base.moveToGroup')"
+        draggable="true"
+        class="bg-white dark:bg-gray-700 invisible group-hover:visible z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
+        @dragstart="handleStartDrag"
+        @mousedown.stop
+      >
+        <v-remixicon name="riDragDropLine" size="20" />
+      </div>
+    </template>
     <div
     <div
-      v-if="
-        block.data.onError?.enable && block.data.onError?.toDo === 'fallback'
-      "
+      v-if="data.onError?.enable && data.onError?.toDo === 'fallback'"
       class="fallback flex items-center justify-end"
       class="fallback flex items-center justify-end"
     >
     >
       <v-remixicon
       <v-remixicon
@@ -51,96 +60,55 @@
         {{ t('common.fallback') }}
         {{ t('common.fallback') }}
       </span>
       </span>
     </div>
     </div>
-    <slot :block="block"></slot>
-    <template #prepend>
-      <div
-        v-if="!editor.minimap && block.details.id !== 'trigger'"
-        :title="t('workflow.blocks.base.moveToGroup')"
-        draggable="true"
-        class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
-        @dragstart="handleStartDrag"
-        @mousedown.stop
-      >
-        <v-remixicon name="riDragDropLine" size="20" />
-      </div>
-    </template>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      v-if="data.onError?.enable && data.onError?.toDo === 'fallback'"
+      :id="`${id}-output-fallback`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 10px"
+    />
   </block-base>
   </block-base>
 </template>
 </template>
 <script setup>
 <script setup>
-import { watch } from 'vue';
+import { Handle, Position } from '@braks/vue-flow';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import BlockBase from './BlockBase.vue';
 import BlockBase from './BlockBase.vue';
 
 
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  position: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
+defineEmits(['delete', 'edit', 'update']);
 
 
 const { t } = useI18n();
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-base');
 const componentId = useComponentId('block-base');
-const block = useEditorBlock(`#${componentId}`, props.editor);
-
-function editBlock() {
-  emitter.emit('editor:edit-block', {
-    ...block.details,
-    data: block.data,
-    blockId: block.id,
-  });
-}
-function handleDataChange() {
-  const { data } = props.editor.getNodeFromId(block.id);
 
 
-  block.data = data;
-}
 function handleStartDrag(event) {
 function handleStartDrag(event) {
   const payload = {
   const payload = {
-    data: block.data,
+    data: props.data,
     id: block.details.id,
     id: block.details.id,
-    blockId: block.id,
+    blockId: props.id,
     fromBlockBasic: true,
     fromBlockBasic: true,
   };
   };
 
 
   event.dataTransfer.setData('block', JSON.stringify(payload));
   event.dataTransfer.setData('block', JSON.stringify(payload));
 }
 }
-
-watch(
-  () => block.data.onError,
-  (onError) => {
-    if (!onError) return;
-
-    const blockDetail = props.editor.getNodeFromId(block.id);
-    const outputLen = Object.keys(blockDetail.outputs).length;
-
-    if (!onError.enable || onError.toDo !== 'fallback') {
-      block.containerEl.classList.toggle('block-basic-fallback', false);
-
-      if (outputLen > 1) props.editor.removeNodeOutput(block.id, 'output_2');
-
-      return;
-    }
-
-    block.containerEl.classList.toggle('block-basic-fallback', true);
-
-    if (outputLen < 2) {
-      props.editor.addNodeOutput(block.id);
-    }
-
-    props.editor.updateConnectionNodes(`node-${block.id}`);
-  },
-  { deep: true }
-);
 </script>
 </script>
-<style>
-.drawflow-node.selected .move-to-group,
-.block-basic:hover .move-to-group {
-  visibility: visible;
-}
-.block-basic-fallback .output_2 {
-  top: 11px;
-}
-</style>

+ 81 - 22
src/components/block/BlockBasicWithFallback.vue

@@ -1,9 +1,37 @@
 <template>
 <template>
-  <block-basic v-slot="{ block }" :editor="editor" class="block-with-fallback">
-    <div class="fallback flex items-center pb-2 justify-end">
+  <block-base
+    :id="componentId"
+    :hide-edit="block.details.disableEdit"
+    :hide-delete="block.details.disableDelete"
+    class="block-basic group"
+    @edit="$emit('edit')"
+    @delete="$emit('delete', id)"
+  >
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
+    <div class="flex items-center">
+      <span
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
+        class="inline-block p-2 mr-2 rounded-lg dark:text-black"
+      >
+        <v-remixicon :name="block.details.icon || 'riGlobalLine'" />
+      </span>
+      <div class="overflow-hidden flex-1">
+        <p
+          v-if="block.details.id"
+          class="font-semibold leading-tight text-overflow whitespace-nowrap"
+        >
+          {{ t(`workflow.blocks.${block.details.id}.name`) }}
+        </p>
+        <p class="text-gray-600 dark:text-gray-200 text-overflow leading-tight">
+          {{ data.description }}
+        </p>
+      </div>
+    </div>
+    <slot :block="block"></slot>
+    <div class="fallback flex items-center justify-end">
       <v-remixicon
       <v-remixicon
         v-if="block"
         v-if="block"
-        :title="t(`workflow.blocks.${block.details.id}.fallback`)"
+        :title="t('workflow.blocks.base.onError.fallbackTitle')"
         name="riInformationLine"
         name="riInformationLine"
         size="18"
         size="18"
       />
       />
@@ -11,31 +39,62 @@
         {{ t('common.fallback') }}
         {{ t('common.fallback') }}
       </span>
       </span>
     </div>
     </div>
-  </block-basic>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      :id="`${id}-output-fallback`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 10px"
+    />
+    <template #prepend>
+      <div
+        v-if="block.details.id !== 'trigger'"
+        :title="t('workflow.blocks.base.moveToGroup')"
+        draggable="true"
+        class="bg-white dark:bg-gray-700 invisible group-hover:visible z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
+        @dragstart="handleStartDrag"
+        @mousedown.stop
+      >
+        <v-remixicon name="riDragDropLine" size="20" />
+      </div>
+    </template>
+  </block-base>
 </template>
 </template>
 <script setup>
 <script setup>
+import { Handle, Position } from '@braks/vue-flow';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
-import BlockBasic from './BlockBasic.vue';
-
-const { t } = useI18n();
+import { useEditorBlock } from '@/composable/editorBlock';
+import { useComponentId } from '@/composable/componentId';
+import BlockBase from './BlockBase.vue';
 
 
-defineProps({
-  editor: {
+const props = defineProps({
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
-</script>
-<style>
-.block-with-fallback .block-base__content {
-  padding-bottom: 0;
-}
-.drawflow-node.webhook .outputs,
-.drawflow-node.while-loop .outputs {
-  top: 64%;
-}
-.drawflow-node.webhook .outputs .output_1,
-.drawflow-node.while-loop .outputs .output_1 {
-  margin-bottom: 14px;
+defineEmits(['delete', 'edit', 'update']);
+
+const { t } = useI18n();
+const block = useEditorBlock(props.label);
+const componentId = useComponentId('block-base');
+
+function handleStartDrag(event) {
+  const payload = {
+    data: block.data,
+    id: block.details.id,
+    blockId: block.id,
+    fromBlockBasic: true,
+  };
+
+  event.dataTransfer.setData('block', JSON.stringify(payload));
 }
 }
-</style>
+</script>

+ 46 - 107
src/components/block/BlockConditions.vue

@@ -1,37 +1,34 @@
 <template>
 <template>
-  <div :id="componentId" class="p-4" @dblclick="editBlock">
+  <ui-card :id="componentId" class="w-64 relative" @dblclick="$emit('edit')">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center">
     <div class="flex items-center">
       <div
       <div
-        :class="
-          block.data.disableBlock ? 'bg-box-transparent' : block.category.color
-        "
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
         class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
       >
       >
         <v-remixicon name="riAB" size="20" class="inline-block mr-1" />
         <v-remixicon name="riAB" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.conditions.name') }}</span>
         <span>{{ t('workflow.blocks.conditions.name') }}</span>
       </div>
       </div>
       <div class="flex-grow"></div>
       <div class="flex-grow"></div>
-      <template v-if="!editor.minimap">
-        <v-remixicon
-          name="riDeleteBin7Line"
-          class="cursor-pointer mr-2"
-          @click="editor.removeNodeId(`node-${block.id}`)"
-        />
-        <v-remixicon
-          name="riPencilLine"
-          class="inline-block cursor-pointer"
-          @click="editBlock"
-        />
-      </template>
+      <v-remixicon
+        name="riDeleteBin7Line"
+        class="cursor-pointer mr-2"
+        @click.stop="$emit('delete', id)"
+      />
+      <v-remixicon
+        name="riPencilLine"
+        class="inline-block cursor-pointer"
+        @click="$emit('edit')"
+      />
     </div>
     </div>
     <ul
     <ul
-      v-if="block.data.conditions && block.data.conditions.length !== 0"
+      v-if="data.conditions && data.conditions.length !== 0"
       class="mt-4 space-y-2"
       class="mt-4 space-y-2"
     >
     >
       <li
       <li
-        v-for="item in block.data.conditions"
+        v-for="item in data.conditions"
         :key="item.id"
         :key="item.id"
-        class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg overflow-hidden w-44"
+        class="flex items-center flex-1 p-2 bg-box-transparent rounded-lg w-full relative"
       >
       >
         <p
         <p
           v-if="item.name"
           v-if="item.name"
@@ -51,118 +48,60 @@
             {{ item.value || '_____' }}
             {{ item.value || '_____' }}
           </p>
           </p>
         </template>
         </template>
+        <Handle
+          :id="`${id}-output-${item.id}`"
+          :position="Position.Right"
+          style="margin-right: -33px"
+          type="source"
+        />
       </li>
       </li>
       <p
       <p
-        v-if="block.data.conditions && block.data.conditions.length !== 0"
+        v-if="data.conditions && data.conditions.length !== 0"
         class="text-right text-gray-600 dark:text-gray-200"
         class="text-right text-gray-600 dark:text-gray-200"
       >
       >
         <span title="Fallback"> &#9432; </span>
         <span title="Fallback"> &#9432; </span>
         Fallback
         Fallback
       </p>
       </p>
     </ul>
     </ul>
-    <input class="trigger hidden" @change="onChange" />
-  </div>
+    <Handle
+      v-if="data.conditions.length > 0"
+      :id="`${id}-output-fallback`"
+      :position="Position.Right"
+      type="source"
+      style="top: auto; bottom: 10px"
+    />
+  </ui-card>
 </template>
 </template>
 <script setup>
 <script setup>
-import { watch, onBeforeUnmount } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
-import { debounce } from '@/utils/helper';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
+defineEmits(['delete', 'edit']);
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 const componentId = useComponentId('block-conditions');
 const componentId = useComponentId('block-conditions');
-const block = useEditorBlock(`#${componentId}`, props.editor);
-
-function onChange({ detail }) {
-  block.data.disableBlock = detail.disableBlock;
-
-  if (detail.conditions) {
-    block.data.conditions = detail.conditions;
-  }
-}
-function editBlock() {
-  emitter.emit('editor:edit-block', {
-    ...block.details,
-    data: block.data,
-    blockId: block.id,
-  });
-}
-function addConditionEmit({ id }) {
-  if (id !== block.id) return;
-
-  const { length } = block.data.conditions;
-
-  if (length >= 20) return;
-  if (length === 0) props.editor.addNodeOutput(block.id);
-
-  props.editor.addNodeOutput(block.id);
-}
-function deleteConditionEmit({ index, id }) {
-  if (id !== block.id) return;
-
-  props.editor.removeNodeOutput(block.id, `output_${index + 1}`);
-
-  if (block.data.conditions.length === 0)
-    props.editor.removeNodeOutput(block.id, `output_1`);
-}
-function refreshConnections({ id }) {
-  if (id !== block.id) return;
-
-  const node = props.editor.getNodeFromId(block.id);
-  const outputs = Object.keys(node.outputs);
-  const conditionsLen = block.data.conditions.length + 1;
-
-  if (outputs.length > conditionsLen) {
-    const diff = outputs.length - conditionsLen;
-
-    for (let index = 0; index < diff; index += 1) {
-      const output = outputs[outputs.length - 2 - index];
-
-      props.editor.removeNodeOutput(block.id, output);
-    }
-  }
-}
-
-watch(
-  () => block.data.conditions,
-  debounce((newValue, oldValue) => {
-    props.editor.updateConnectionNodes(`node-${block.id}`);
-
-    if (!oldValue) return;
-
-    emitter.emit('editor:data-changed', block.id);
-  }, 250),
-  { deep: true }
-);
-
-emitter.on('conditions-block:add', addConditionEmit);
-emitter.on('conditions-block:delete', deleteConditionEmit);
-emitter.on('conditions-block:refresh', refreshConnections);
-
-onBeforeUnmount(() => {
-  emitter.off('conditions-block:add', addConditionEmit);
-  emitter.off('conditions-block:delete', deleteConditionEmit);
-  emitter.off('conditions-block:refresh', refreshConnections);
-});
+const block = useEditorBlock(props.label);
 </script>
 </script>
 <style>
 <style>
-.drawflow .drawflow-node.conditions .outputs {
+.condition-handle {
+  position: relative !important;
   top: 82px !important;
   top: 82px !important;
-  transform: none !important;
-}
-.drawflow .drawflow-node.conditions .output {
-  margin-bottom: 30px;
-}
-.drawflow .drawflow-node.conditions .output:nth-last-child(2) {
-  margin-bottom: 22px;
+  margin-bottom: 32px !important;
 }
 }
 </style>
 </style>

+ 24 - 18
src/components/block/BlockDelay.vue

@@ -1,5 +1,6 @@
 <template>
 <template>
-  <div :id="componentId" class="p-4 block-basic">
+  <ui-card :id="componentId" class="p-4 w-48 block-basic">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
     <div class="flex items-center mb-2">
       <div
       <div
         :class="block.category.color"
         :class="block.category.color"
@@ -10,24 +11,23 @@
       </div>
       </div>
       <div class="flex-grow"></div>
       <div class="flex-grow"></div>
       <v-remixicon
       <v-remixicon
-        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         name="riDeleteBin7Line"
         class="cursor-pointer"
         class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
+        @click.stop="$emit('delete', id)"
       />
       />
     </div>
     </div>
     <input
     <input
-      :value="block.data.time"
+      :value="data.time"
       min="0"
       min="0"
       :title="t('workflow.blocks.delay.input.title')"
       :title="t('workflow.blocks.delay.input.title')"
       :placeholder="t('workflow.blocks.delay.input.placeholder')"
       :placeholder="t('workflow.blocks.delay.input.placeholder')"
-      class="px-4 py-2 rounded-lg w-36 bg-input"
+      class="px-4 py-2 w-full rounded-lg bg-input"
       type="text"
       type="text"
       required
       required
-      @input="handleInput"
+      @input="$emit('update', { time: $event.target.value })"
     />
     />
     <div
     <div
-      v-if="!editor.minimap && block.details.id !== 'trigger'"
+      v-if="block.details.id !== 'trigger'"
       :title="t('workflow.blocks.base.moveToGroup')"
       :title="t('workflow.blocks.base.moveToGroup')"
       draggable="true"
       draggable="true"
       class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
       class="bg-white dark:bg-gray-700 invisible move-to-group z-50 absolute -top-2 -right-2 rounded-md p-1 shadow-md"
@@ -36,34 +36,40 @@
     >
     >
       <v-remixicon name="riDragDropLine" size="20" />
       <v-remixicon name="riDragDropLine" size="20" />
     </div>
     </div>
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+  </ui-card>
 </template>
 </template>
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
-import emitter from '@/lib/mitt';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
+defineEmits(['update', 'delete']);
 
 
 const { t } = useI18n();
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 
-function handleInput({ target }) {
-  props.editor.updateNodeDataFromId(block.id, { time: target.value });
-  emitter.emit('editor:data-changed', block.id);
-}
 function handleStartDrag(event) {
 function handleStartDrag(event) {
   const payload = {
   const payload = {
-    data: block.data,
-    id: block.details.id,
-    blockId: block.id,
+    id: props.label,
+    data: props.data,
+    blockId: props.id,
     fromBlockBasic: true,
     fromBlockBasic: true,
   };
   };
 
 

+ 25 - 21
src/components/block/BlockElementExists.vue

@@ -1,16 +1,14 @@
 <template>
 <template>
   <block-base
   <block-base
     :id="componentId"
     :id="componentId"
-    :minimap="editor.minimap"
     class="element-exists"
     class="element-exists"
     style="width: 195px"
     style="width: 195px"
     @edit="editBlock"
     @edit="editBlock"
-    @delete="editor.removeNodeId(`node-${block.id}`)"
+    @delete="$emit('delete', id)"
   >
   >
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div
     <div
-      :class="
-        block.data.disableBlock ? 'bg-box-transparent' : block.category.color
-      "
+      :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
       class="inline-block text-sm mb-2 p-2 rounded-lg dark:text-black"
       class="inline-block text-sm mb-2 p-2 rounded-lg dark:text-black"
     >
     >
       <v-remixicon name="riFocus3Line" size="20" class="inline-block mr-1" />
       <v-remixicon name="riFocus3Line" size="20" class="inline-block mr-1" />
@@ -18,13 +16,13 @@
     </div>
     </div>
     <p
     <p
       :title="t('workflow.blocks.element-exists.selector')"
       :title="t('workflow.blocks.element-exists.selector')"
-      :class="{ 'font-mono': !block.data.description }"
+      :class="{ 'font-mono': !data.description }"
       class="text-overflow p-2 rounded-lg bg-box-transparent text-sm text-right mb-2"
       class="text-overflow p-2 rounded-lg bg-box-transparent text-sm text-right mb-2"
       style="max-width: 200px"
       style="max-width: 200px"
     >
     >
       {{
       {{
-        block.data.description ||
-        block.data.selector ||
+        data.description ||
+        data.selector ||
         t('workflow.blocks.element-exists.selector')
         t('workflow.blocks.element-exists.selector')
       }}
       }}
     </p>
     </p>
@@ -34,44 +32,50 @@
       </span>
       </span>
       {{ t('common.fallback') }}
       {{ t('common.fallback') }}
     </p>
     </p>
-    <input
-      type="text"
-      class="hidden trigger"
-      disabled="true"
-      @change="handleDataChanged"
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      :id="`${id}-output-2`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 12px"
     />
     />
   </block-base>
   </block-base>
 </template>
 </template>
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
+import { Handle, Position } from '@braks/vue-flow';
 import emitter from '@/lib/mitt';
 import emitter from '@/lib/mitt';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 import BlockBase from './BlockBase.vue';
 import BlockBase from './BlockBase.vue';
 
 
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
+defineEmits(['delete']);
 
 
 const { t } = useI18n();
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 
 function editBlock() {
 function editBlock() {
   emitter.emit('editor:edit-block', {
   emitter.emit('editor:edit-block', {
     ...block.details,
     ...block.details,
-    data: block.data,
+    data: props.data,
     blockId: block.id,
     blockId: block.id,
   });
   });
 }
 }
-function handleDataChanged() {
-  const { data } = props.editor.getNodeFromId(block.id);
-
-  block.data = data;
-}
 </script>
 </script>
 <style>
 <style>
 .drawflow .element-exists .outputs {
 .drawflow .element-exists .outputs {

+ 0 - 64
src/components/block/BlockExportData.vue

@@ -1,64 +0,0 @@
-<template>
-  <div :id="componentId" class="p-4">
-    <div class="flex items-center mb-2">
-      <div
-        :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg"
-      >
-        <v-remixicon
-          name="riDownloadLine"
-          size="20"
-          class="inline-block mr-1"
-        />
-        <span>{{ t('workflow.blocks.export-data.name') }}</span>
-      </div>
-      <div class="flex-grow"></div>
-      <v-remixicon
-        name="riDeleteBin7Line"
-        class="cursor-pointer"
-        @click="editor.removeNodeId(`node-${block.id}`)"
-      />
-    </div>
-    <input
-      v-model="block.data.name"
-      :placeholder="t('common.fileName')"
-      class="bg-input rounded-lg transition w-40 mb-2 py-2 px-4 block"
-    />
-    <ui-select v-model="block.data.type" class="w-40" placeholder="Export as">
-      <option v-for="type in dataExportTypes" :key="type.id" :value="type.id">
-        {{ type.name }}
-      </option>
-    </ui-select>
-  </div>
-</template>
-<script setup>
-import { useI18n } from 'vue-i18n';
-import { watch } from 'vue';
-import emitter from '@/lib/mitt';
-import { dataExportTypes } from '@/utils/shared';
-import { debounce } from '@/utils/helper';
-import { useComponentId } from '@/composable/componentId';
-import { useEditorBlock } from '@/composable/editorBlock';
-
-const props = defineProps({
-  editor: {
-    type: Object,
-    default: () => ({}),
-  },
-});
-
-const { t } = useI18n();
-const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
-
-watch(
-  () => block.data,
-  debounce((value, oldValue) => {
-    if (Object.keys(oldValue).length === 0) return;
-
-    props.editor.updateNodeDataFromId(block.id, value);
-    emitter.emit('editor:data-changed', block.id);
-  }, 250),
-  { deep: true }
-);
-</script>

+ 48 - 46
src/components/block/BlockGroup.vue

@@ -1,5 +1,6 @@
 <template>
 <template>
-  <div :id="componentId" class="w-64">
+  <ui-card :id="componentId" class="w-64" padding="p-0">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="p-4">
     <div class="p-4">
       <div class="flex items-center mb-2">
       <div class="flex items-center mb-2">
         <div
         <div
@@ -17,20 +18,21 @@
         <v-remixicon
         <v-remixicon
           name="riDeleteBin7Line"
           name="riDeleteBin7Line"
           class="cursor-pointer"
           class="cursor-pointer"
-          @click="editor.removeNodeId(`node-${block.id}`)"
+          @click.stop="emit('delete', id)"
         />
         />
       </div>
       </div>
       <input
       <input
-        v-model="block.data.name"
+        :model-value="data.name"
         :placeholder="t('workflow.blocks.blocks-group.groupName')"
         :placeholder="t('workflow.blocks.blocks-group.groupName')"
         type="text"
         type="text"
         class="bg-transparent w-full focus:ring-0"
         class="bg-transparent w-full focus:ring-0"
+        @input="$emit('update', { name: $event.target.value })"
       />
       />
     </div>
     </div>
     <draggable
     <draggable
-      v-model="block.data.blocks"
+      v-model="state.blocks"
       item-key="itemId"
       item-key="itemId"
-      class="px-4 mb-4 overflow-auto scroll text-sm space-y-1 max-h-60"
+      class="px-4 mb-4 overflow-auto nowheel scroll text-sm space-y-1 max-h-60"
       @mousedown.stop
       @mousedown.stop
       @dragover.prevent
       @dragover.prevent
       @drop="handleDrop"
       @drop="handleDrop"
@@ -58,7 +60,7 @@
               {{ element.data.description }}
               {{ element.data.description }}
             </p>
             </p>
           </div>
           </div>
-          <div v-if="!editor.minimap" class="invisible group-hover:visible">
+          <div class="invisible group-hover:visible">
             <v-remixicon
             <v-remixicon
               name="riPencilLine"
               name="riPencilLine"
               size="20"
               size="20"
@@ -82,31 +84,36 @@
         </div>
         </div>
       </template>
       </template>
     </draggable>
     </draggable>
-    <input class="hidden trigger" @change="handleDataChange" />
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+  </ui-card>
 </template>
 </template>
 <script setup>
 <script setup>
-import { watch } from 'vue';
+import { watch, reactive, onMounted } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
 import { nanoid } from 'nanoid';
 import { useToast } from 'vue-toastification';
 import { useToast } from 'vue-toastification';
+import { Handle, Position } from '@braks/vue-flow';
+import cloneDeep from 'lodash.clonedeep';
 import draggable from 'vuedraggable';
 import draggable from 'vuedraggable';
-import emitter from '@/lib/mitt';
 import { tasks } from '@/utils/shared';
 import { tasks } from '@/utils/shared';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
-
-const { t } = useI18n();
-const toast = useToast();
-const componentId = useComponentId('blocks-group');
-const block = useEditorBlock(`#${componentId}`, props.editor);
+const emit = defineEmits(['update', 'delete', 'edit']);
 
 
 const excludeBlocks = [
 const excludeBlocks = [
   'trigger',
   'trigger',
@@ -118,6 +125,16 @@ const excludeBlocks = [
   'element-exists',
   'element-exists',
 ];
 ];
 
 
+const { t } = useI18n();
+const toast = useToast();
+const componentId = useComponentId('blocks-group');
+const block = useEditorBlock(props.label);
+
+const state = reactive({
+  blocks: [],
+  retrieved: false,
+});
+
 function onDragStart(item, event) {
 function onDragStart(item, event) {
   event.dataTransfer.setData(
   event.dataTransfer.setData(
     'block',
     'block',
@@ -129,39 +146,21 @@ function onDragEnd(itemId) {
     const blockEl = document.querySelector(`[group-item-id="${itemId}"]`);
     const blockEl = document.querySelector(`[group-item-id="${itemId}"]`);
 
 
     if (blockEl) {
     if (blockEl) {
-      const blockIndex = block.data.blocks.findIndex(
+      const blockIndex = state.blocks.findIndex(
         (item) => item.itemId === itemId
         (item) => item.itemId === itemId
       );
       );
 
 
       if (blockIndex !== -1) {
       if (blockIndex !== -1) {
-        emitter.emit('editor:delete-block', { itemId, isInGroup: true });
-        block.data.blocks.splice(blockIndex, 1);
+        state.blocks.splice(blockIndex, 1);
       }
       }
     }
     }
   }, 200);
   }, 200);
 }
 }
-function handleDataChange({ detail }) {
-  if (!detail) return;
-
-  const itemIndex = block.data.blocks.findIndex(
-    ({ itemId }) => itemId === detail.itemId
-  );
-
-  if (itemIndex === -1) return;
-
-  block.data.blocks[itemIndex].data = detail.data;
-}
 function editBlock(payload) {
 function editBlock(payload) {
-  emitter.emit('editor:edit-block', {
-    ...tasks[payload.id],
-    ...payload,
-    isInGroup: true,
-    blockId: block.id,
-  });
+  emit('edit', payload);
 }
 }
-function deleteItem(index, itemId) {
-  emitter.emit('editor:delete-block', { itemId, isInGroup: true });
-  block.data.blocks.splice(index, 1);
+function deleteItem(index) {
+  state.blocks.splice(index, 1);
 }
 }
 function handleDrop(event) {
 function handleDrop(event) {
   event.preventDefault();
   event.preventDefault();
@@ -184,20 +183,23 @@ function handleDrop(event) {
   }
   }
 
 
   if (blockId) {
   if (blockId) {
-    props.editor.removeNodeId(`node-${blockId}`);
+    emit('delete', id);
   }
   }
 
 
-  block.data.blocks.push({ id, data, itemId: nanoid(5) });
+  state.blocks.push({ id, data, itemId: nanoid(5) });
 }
 }
 
 
 watch(
 watch(
-  () => block.data,
-  (value, oldValue) => {
-    if (Object.keys(oldValue).length === 0) return;
+  () => state.blocks,
+  () => {
+    if (!state.retrieved) return;
 
 
-    props.editor.updateNodeDataFromId(block.id, value);
-    emitter.emit('editor:data-changed', block.id);
+    emit('update', { blocks: state.blocks });
   },
   },
   { deep: true }
   { deep: true }
 );
 );
+
+onMounted(() => {
+  state.blocks = cloneDeep(props.data.blocks);
+});
 </script>
 </script>

+ 18 - 8
src/components/block/BlockLoopBreakpoint.vue

@@ -1,47 +1,57 @@
 <template>
 <template>
-  <div :id="componentId" class="p-4">
+  <ui-card :id="componentId" class="w-48">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
     <div class="flex items-center mb-2">
       <div
       <div
         :class="block.category.color"
         :class="block.category.color"
-        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black text-overflow"
       >
       >
         <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
         <v-remixicon name="riStopLine" size="20" class="inline-block mr-1" />
         <span>{{ t('workflow.blocks.loop-breakpoint.name') }}</span>
         <span>{{ t('workflow.blocks.loop-breakpoint.name') }}</span>
       </div>
       </div>
       <div class="flex-grow"></div>
       <div class="flex-grow"></div>
       <v-remixicon
       <v-remixicon
-        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         name="riDeleteBin7Line"
         class="cursor-pointer"
         class="cursor-pointer"
         @click="editor.removeNodeId(`node-${block.id}`)"
         @click="editor.removeNodeId(`node-${block.id}`)"
       />
       />
     </div>
     </div>
     <input
     <input
-      :value="block.data.loopId"
-      class="px-4 py-2 rounded-lg w-48 bg-input"
+      :value="data.loopId"
+      class="px-4 py-2 rounded-lg w-full bg-input"
       placeholder="Loop ID"
       placeholder="Loop ID"
       type="text"
       type="text"
       required
       required
       @input="handleInput"
       @input="handleInput"
     />
     />
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+  </ui-card>
 </template>
 </template>
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import emitter from '@/lib/mitt';
 import emitter from '@/lib/mitt';
+import { Handle, Position } from '@braks/vue-flow';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
 
 
 const { t } = useI18n();
 const { t } = useI18n();
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 
 function handleInput({ target }) {
 function handleInput({ target }) {
   const loopId = target.value.replace(/\s/g, '');
   const loopId = target.value.replace(/\s/g, '');

+ 24 - 8
src/components/block/BlockRepeatTask.vue

@@ -1,5 +1,6 @@
 <template>
 <template>
-  <div :id="componentId" class="p-4 repeat-task">
+  <ui-card :id="componentId" class="p-4 repeat-task">
+    <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
     <div class="flex items-center mb-2">
       <div
       <div
         :class="block.category.color"
         :class="block.category.color"
@@ -10,7 +11,6 @@
       </div>
       </div>
       <div class="flex-grow"></div>
       <div class="flex-grow"></div>
       <v-remixicon
       <v-remixicon
-        v-if="!editor.minimap"
         name="riDeleteBin7Line"
         name="riDeleteBin7Line"
         class="cursor-pointer"
         class="cursor-pointer"
         @click="editor.removeNodeId(`node-${block.id}`)"
         @click="editor.removeNodeId(`node-${block.id}`)"
@@ -20,7 +20,7 @@
       class="mb-2 block bg-input focus-within:bg-input pr-4 transition rounded-lg"
       class="mb-2 block bg-input focus-within:bg-input pr-4 transition rounded-lg"
     >
     >
       <input
       <input
-        :value="block.data.repeatFor || 0"
+        :value="data.repeatFor || 0"
         min="0"
         min="0"
         class="pl-4 py-2 bg-transparent rounded-l-lg w-24 mr-2"
         class="pl-4 py-2 bg-transparent rounded-l-lg w-24 mr-2"
         type="number"
         type="number"
@@ -34,24 +34,40 @@
     <p class="text-right text-gray-600 dark:text-gray-200">
     <p class="text-right text-gray-600 dark:text-gray-200">
       {{ t('workflow.blocks.repeat-task.repeatFrom') }}
       {{ t('workflow.blocks.repeat-task.repeatFrom') }}
     </p>
     </p>
-  </div>
+    <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
+    <Handle
+      :id="`${id}-output-2`"
+      type="source"
+      :position="Position.Right"
+      style="top: auto; bottom: 12px"
+    />
+  </ui-card>
 </template>
 </template>
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
+import { Handle, Position } from '@braks/vue-flow';
 import emitter from '@/lib/mitt';
 import emitter from '@/lib/mitt';
 import { useComponentId } from '@/composable/componentId';
 import { useComponentId } from '@/composable/componentId';
 import { useEditorBlock } from '@/composable/editorBlock';
 import { useEditorBlock } from '@/composable/editorBlock';
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 const props = defineProps({
 const props = defineProps({
-  editor: {
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
 
 
+const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
 const componentId = useComponentId('block-delay');
-const block = useEditorBlock(`#${componentId}`, props.editor);
 
 
 function handleInput({ target }) {
 function handleInput({ target }) {
   target.reportValidity();
   target.reportValidity();
@@ -60,8 +76,8 @@ function handleInput({ target }) {
 
 
   if (repeatFor < 0) return;
   if (repeatFor < 0) return;
 
 
-  props.editor.updateNodeDataFromId(block.id, { repeatFor });
-  emitter.emit('editor:data-changed', block.id);
+  props.editor.updateNodeDataFromId(props.id, { repeatFor });
+  emitter.emit('editor:data-changed', props.id);
 }
 }
 </script>
 </script>
 <style>
 <style>

+ 22 - 9
src/components/block/BlockWebhook.vue

@@ -1,8 +1,9 @@
 <template>
 <template>
-  <block-basic :editor="editor" class="block-webhook">
+  <block-basic v-bind="props" class="block-with-fallback">
     <div class="fallback flex items-center pb-2 justify-end">
     <div class="fallback flex items-center pb-2 justify-end">
       <v-remixicon
       <v-remixicon
-        :title="t('workflow.blocks.webhook.fallback')"
+        v-if="block"
+        :title="t(`workflow.blocks.${block.details.id}.fallback`)"
         name="riInformationLine"
         name="riInformationLine"
         size="18"
         size="18"
       />
       />
@@ -14,25 +15,37 @@
 </template>
 </template>
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
+import { useEditorBlock } from '@/composable/editorBlock';
 import BlockBasic from './BlockBasic.vue';
 import BlockBasic from './BlockBasic.vue';
 
 
-const { t } = useI18n();
-
-defineProps({
-  editor: {
+const props = defineProps({
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
 });
 });
+
+const { t } = useI18n();
+const block = useEditorBlock(props.label);
 </script>
 </script>
 <style>
 <style>
-.block-webhook .block-base__content {
+.block-with-fallback .block-base__content {
   padding-bottom: 0;
   padding-bottom: 0;
 }
 }
-.drawflow-node.webhook .outputs {
+.drawflow-node.webhook .outputs,
+.drawflow-node.while-loop .outputs {
   top: 64%;
   top: 64%;
 }
 }
-.drawflow-node.webhook .outputs .output_1 {
+.drawflow-node.webhook .outputs .output_1,
+.drawflow-node.while-loop .outputs .output_1 {
   margin-bottom: 14px;
   margin-bottom: 14px;
 }
 }
 </style>
 </style>

+ 8 - 6
src/components/newtab/app/AppSidebar.vue

@@ -47,18 +47,18 @@
       </router-link>
       </router-link>
     </div>
     </div>
     <div class="flex-grow"></div>
     <div class="flex-grow"></div>
-    <ui-popover v-if="store.state.user" trigger="mouseenter" placement="right">
+    <ui-popover v-if="userStore.user" trigger="mouseenter" placement="right">
       <template #trigger>
       <template #trigger>
         <span class="inline-block p-1 bg-box-transparent rounded-full">
         <span class="inline-block p-1 bg-box-transparent rounded-full">
           <img
           <img
-            :src="store.state.user.avatar_url"
+            :src="userStore.user.avatar_url"
             height="32"
             height="32"
             width="32"
             width="32"
             class="rounded-full"
             class="rounded-full"
           />
           />
         </span>
         </span>
       </template>
       </template>
-      {{ store.state.user.username }}
+      {{ userStore.user.username }}
     </ui-popover>
     </ui-popover>
     <ui-popover trigger="mouseenter" placement="right" class="my-4">
     <ui-popover trigger="mouseenter" placement="right" class="my-4">
       <template #trigger>
       <template #trigger>
@@ -87,10 +87,11 @@
 </template>
 </template>
 <script setup>
 <script setup>
 import { ref, computed } from 'vue';
 import { ref, computed } from 'vue';
-import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import { useRouter } from 'vue-router';
 import { useRouter } from 'vue-router';
 import browser from 'webextension-polyfill';
 import browser from 'webextension-polyfill';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { communities } from '@/utils/shared';
 import { communities } from '@/utils/shared';
@@ -98,8 +99,9 @@ import { communities } from '@/utils/shared';
 useGroupTooltip();
 useGroupTooltip();
 
 
 const { t } = useI18n();
 const { t } = useI18n();
-const store = useStore();
 const router = useRouter();
 const router = useRouter();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
 
 
 const extensionVersion = browser.runtime.getManifest().version;
 const extensionVersion = browser.runtime.getManifest().version;
 const tabs = [
 const tabs = [
@@ -136,7 +138,7 @@ const tabs = [
 ];
 ];
 const hoverIndicator = ref(null);
 const hoverIndicator = ref(null);
 const showHoverIndicator = ref(false);
 const showHoverIndicator = ref(false);
-const runningWorkflowsLen = computed(() => store.state.workflowState.length);
+const runningWorkflowsLen = computed(() => workflowStore.states.length);
 
 
 useShortcut(
 useShortcut(
   tabs.map(({ shortcut }) => shortcut),
   tabs.map(({ shortcut }) => shortcut),

+ 100 - 0
src/components/newtab/app/AppSurvey.vue

@@ -0,0 +1,100 @@
+<template>
+  <ui-card
+    v-if="modalState.show"
+    class="fixed bottom-8 right-8 shadow-2xl border-2 w-72 group"
+  >
+    <button
+      class="absolute bg-white shadow-md rounded-full -right-2 -top-2 transition scale-0 group-hover:scale-100"
+      @click="closeModal"
+    >
+      <v-remixicon class="text-gray-600" name="riCloseLine" />
+    </button>
+    <h2 class="font-semibold text-lg">
+      {{ activeModal.title }}
+    </h2>
+    <p class="mt-1 dark:text-gray-100 text-gray-700">
+      {{ activeModal.body }}
+    </p>
+    <div class="space-y-2 mt-4">
+      <ui-button
+        :href="activeModal.url"
+        tag="a"
+        target="_blank"
+        rel="noopener"
+        class="w-full block"
+        variant="accent"
+      >
+        {{ activeModal.button }}
+      </ui-button>
+    </div>
+  </ui-card>
+</template>
+<script setup>
+import { shallowReactive, computed, onMounted } from 'vue';
+import browser from 'webextension-polyfill';
+import dayjs from '@/lib/dayjs';
+
+const modalTypes = {
+  testimonial: {
+    title: 'Hi There 👋',
+    body: 'Thank you for using Automa, and if you have a great experience. Would you like to give us a testimonial?',
+    button: 'Give Testimonial',
+    url: 'https://testimonial.to/automa',
+  },
+  survey: {
+    title: "How do you think we're doing?",
+    body: 'To help us make Automa as best it can be, we need a few minutes of your time to get your feedback.',
+    button: 'Take Survey',
+    url: 'https://www.automa.site/survey',
+  },
+};
+
+const modalState = shallowReactive({
+  show: true,
+  type: 'survey',
+});
+
+function closeModal() {
+  let value = true;
+
+  if (modalState.type === 'survey') {
+    value = new Date().toString();
+  }
+
+  modalState.show = false;
+  localStorage.setItem(`has-${modalState.type}`, value);
+}
+async function checkModal() {
+  try {
+    const { isFirstTime } = await browser.storage.local.get('isFirstTime');
+
+    if (isFirstTime) {
+      modalState.show = false;
+      localStorage.setItem('has-testimonial', true);
+      localStorage.setItem('has-survey', Date.now());
+      return;
+    }
+
+    const survey = localStorage.getItem('has-survey');
+
+    if (!survey) return;
+
+    const daysDiff = dayjs().diff(survey, 'day');
+    const showTestimonial =
+      daysDiff >= 2 && !localStorage.getItem('has-testimonial');
+
+    if (showTestimonial) {
+      modalState.show = true;
+      modalState.type = 'testimonial';
+    } else {
+      modalState.show = false;
+    }
+  } catch (error) {
+    console.error(error);
+  }
+}
+
+const activeModal = computed(() => modalTypes[modalState.type]);
+
+onMounted(checkModal);
+</script>

+ 3 - 3
src/components/newtab/workflow/WorkflowBuilder.vue

@@ -34,7 +34,7 @@
             <v-remixicon name="riAddLine" />
             <v-remixicon name="riAddLine" />
           </button>
           </button>
         </div>
         </div>
-        <workflow-builder-search-blocks :editor="editor" />
+        <!-- <workflow-builder-search-blocks :editor="editor" /> -->
       </div>
       </div>
       <slot v-bind="{ editor }"></slot>
       <slot v-bind="{ editor }"></slot>
     </div>
     </div>
@@ -93,10 +93,10 @@ import { tasks, excludeOnError } from '@/utils/shared';
 import { parseJSON } from '@/utils/helper';
 import { parseJSON } from '@/utils/helper';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import drawflow from '@/lib/drawflow';
 import drawflow from '@/lib/drawflow';
-import WorkflowBuilderSearchBlocks from './WorkflowBuilderSearchBlocks.vue';
+// import WorkflowBuilderSearchBlocks from './WorkflowBuilderSearchBlocks.vue';
 
 
 export default {
 export default {
-  components: { WorkflowBuilderSearchBlocks },
+  // components: { WorkflowBuilderSearchBlocks },
   props: {
   props: {
     data: {
     data: {
       type: [Object, String],
       type: [Object, String],

+ 2 - 17
src/components/newtab/workflow/WorkflowDetailsCard.vue

@@ -1,6 +1,6 @@
 <template>
 <template>
   <div class="px-4 flex items-start mb-2 mt-1">
   <div class="px-4 flex items-start mb-2 mt-1">
-    <ui-popover :disabled="data.active === 'shared'" class="mr-2 h-8">
+    <ui-popover class="mr-2 h-8">
       <template #trigger>
       <template #trigger>
         <span
         <span
           :title="t('workflow.sidebar.workflowIcon')"
           :title="t('workflow.sidebar.workflowIcon')"
@@ -47,24 +47,13 @@
   <ui-input
   <ui-input
     id="search-input"
     id="search-input"
     v-model="query"
     v-model="query"
-    :disabled="data.active === 'shared'"
     :placeholder="`${t('common.search')}... (${
     :placeholder="`${t('common.search')}... (${
       shortcut['action:search'].readable
       shortcut['action:search'].readable
     })`"
     })`"
     prepend-icon="riSearch2Line"
     prepend-icon="riSearch2Line"
     class="px-4 mt-4 mb-2"
     class="px-4 mt-4 mb-2"
   />
   />
-  <div
-    :class="[data.active === 'shared' ? 'overflow-hidden' : 'overflow-auto']"
-    class="scroll bg-scroll px-4 flex-1 relative"
-  >
-    <div
-      v-show="data.active === 'shared'"
-      class="absolute h-full w-full bg-white dark:bg-black bg-opacity-10 dark:bg-opacity-10 backdrop-blur rounded-lg z-10 flex items-center justify-center"
-      style="top: 0; left: 50%; transform: translateX(-50%); width: 95%"
-    >
-      <p>{{ t('workflow.cantEdit') }}</p>
-    </div>
+  <div class="scroll bg-scroll px-4 flex-1 relative overflow-auto">
     <ui-expand
     <ui-expand
       v-for="(items, catId) in blocks"
       v-for="(items, catId) in blocks"
       :key="catId"
       :key="catId"
@@ -122,10 +111,6 @@ defineProps({
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
-  data: {
-    type: Object,
-    default: () => ({}),
-  },
   dataChanged: {
   dataChanged: {
     type: Boolean,
     type: Boolean,
     default: false,
     default: false,

+ 32 - 160
src/components/newtab/workflow/WorkflowEditBlock.vue

@@ -33,12 +33,13 @@
       />
       />
     </div>
     </div>
     <component
     <component
-      :is="data.editComponent"
+      :is="components[data.editComponent]"
       v-if="blockData"
       v-if="blockData"
       :key="data.itemId || data.blockId"
       :key="data.itemId || data.blockId"
       v-model:data="blockData"
       v-model:data="blockData"
       :block-id="data.blockId"
       :block-id="data.blockId"
       v-bind="{
       v-bind="{
+        editor: data.id === 'conditions' ? editor : null,
         connections: data.id === 'wait-connections' ? data.connections : null,
         connections: data.id === 'wait-connections' ? data.connections : null,
       }"
       }"
     />
     />
@@ -51,11 +52,10 @@
     />
     />
   </div>
   </div>
 </template>
 </template>
-<script>
-import { computed, provide, ref, watch } from 'vue';
+<script setup>
+import { computed } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
-import { tasks, excludeOnError } from '@/utils/shared';
-import { parseJSON } from '@/utils/helper';
+import { excludeOnError } from '@/utils/shared';
 import OnBlockError from './edit/OnBlockError.vue';
 import OnBlockError from './edit/OnBlockError.vue';
 
 
 const editComponents = require.context(
 const editComponents = require.context(
@@ -74,165 +74,37 @@ const components = editComponents.keys().reduce((acc, key) => {
   return acc;
   return acc;
 }, {});
 }, {});
 
 
-export default {
-  components: { ...components, OnBlockError },
-  props: {
-    data: {
-      type: Object,
-      default: () => ({}),
-    },
-    editor: {
-      type: Object,
-      default: () => ({}),
-    },
-    workflow: {
-      type: Object,
-      default: () => ({}),
-    },
-    autocomplete: {
-      type: Object,
-      default: () => ({}),
-    },
-    dataChanged: Boolean,
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
   },
   },
-  emits: ['close', 'update', 'update:autocomplete'],
-  setup(props, { emit }) {
-    const { t } = useI18n();
-    const autocompleteData = ref({
-      common: {
-        table: {},
-        globalData: [],
-        activeTabUrl: '',
-        prevBlockData: '',
-        $date: '',
-        $randint: '',
-        $getLength: '',
-        $randData: '',
-      },
-    });
-
-    const blockData = computed({
-      get() {
-        return props.data.data || {};
-      },
-      set(value) {
-        emit('update', value);
-      },
-    });
-    const autocompleteList = computed(() => ({
-      ...autocompleteData.value.common,
-      ...autocompleteData.value[props.data.itemId || props.data.blockId],
-    }));
-
-    provide('autocompleteData', autocompleteList);
-
-    const dataKeywords = {
-      loopId: 'loopData',
-      refKey: 'googleSheets',
-      variableName: 'variables',
-    };
-    function addAutocompleteData(id, name, data) {
-      if (!autocompleteData.value[id]) autocompleteData.value[id] = {};
-
-      if (!tasks[name].autocomplete) return;
-
-      tasks[name].autocomplete.forEach((key) => {
-        const variableNotAssigned =
-          key === 'variableName' && !data.assignVariable;
-        if (!data[key] || variableNotAssigned) return;
-
-        const keyword = dataKeywords[key];
-        if (!autocompleteData.value[id][keyword]) {
-          autocompleteData.value[id][keyword] = {};
-        }
-
-        autocompleteData.value[id][keyword][data[key]] = '';
-      });
-    }
-    function getGroupBlockData(blocks, currentItemId) {
-      let itemFound = currentItemId || true;
-      const blockId = currentItemId || props.data.blockId;
-
-      for (let index = blocks.length - 1; index > 0; index -= 1) {
-        const { id, data, itemId } = blocks[index];
-
-        if (itemFound) {
-          addAutocompleteData(blockId, id, data);
-        } else {
-          itemFound = itemId === currentItemId;
-        }
-      }
-    }
-    function traceBlockData(
-      blockId,
-      { name, inputs, data, id },
-      blocks,
-      maxDepth = 20
-    ) {
-      const notFirstDepth = maxDepth !== 20;
-
-      if (maxDepth === 0 || (blockId === id && notFirstDepth)) return;
-
-      if (notFirstDepth) {
-        if (name === 'blocks-group') getGroupBlockData(data.blocks);
-        else addAutocompleteData(props.data.blockId, name, data);
-      }
-
-      inputs?.input_1?.connections.forEach(({ node }) => {
-        traceBlockData(blockId, blocks[node], blocks, maxDepth - 1);
-      });
-    }
-
-    watch(
-      () => [props.data.blockId, props.data.itemId],
-      () => {
-        const enableAutocomplete =
-          props.workflow.settings?.inputAutocomplete ?? true;
-
-        if (!enableAutocomplete) return;
-
-        const id = props.data.blockId;
-        const isDataChanging =
-          !props.autocomplete || !props.autocomplete[id] || props.dataChanged;
-        if (isDataChanging) {
-          const blocks = props.editor.export().drawflow.Home.data;
-          const currentBlock = blocks[id];
-
-          if (Object.keys(blocks).length > 32) return;
-
-          if (props.data.isInGroup)
-            getGroupBlockData(currentBlock.data.blocks, props.data.itemId);
-
-          traceBlockData(props.data.blockId, currentBlock, blocks);
-        }
-
-        props.workflow.table?.forEach((column) => {
-          autocompleteData.value.common.table[column.name] = '';
-        });
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  autocomplete: {
+    type: Object,
+    default: () => ({}),
+  },
+  dataChanged: Boolean,
+});
+const emit = defineEmits(['close', 'update', 'update:autocomplete']);
 
 
-        const workflowGlobalData = props.workflow.globalData;
-        autocompleteData.value.common.globalData = parseJSON(
-          workflowGlobalData,
-          workflowGlobalData
-        );
-      },
-      { immediate: true }
-    );
-    watch(
-      autocompleteData,
-      () => {
-        emit('update:autocomplete', autocompleteData.value);
-      },
-      { deep: true }
-    );
+const { t } = useI18n();
 
 
-    return {
-      t,
-      blockData,
-      excludeOnError,
-    };
+const blockData = computed({
+  get() {
+    return props.data.data;
+  },
+  set(data) {
+    emit('update', data);
   },
   },
-};
+});
 </script>
 </script>
 <style>
 <style>
 #workflow-edit-block hr {
 #workflow-edit-block hr {

+ 129 - 0
src/components/newtab/workflow/WorkflowEditor.vue

@@ -0,0 +1,129 @@
+<template>
+  <vue-flow :id="props.id">
+    <Background />
+    <MiniMap :node-class-name="minimapNodeClassName" />
+    <div class="flex items-end absolute p-4 left-0 bottom-0 z-10">
+      <button
+        v-tooltip.group="t('workflow.editor.resetZoom')"
+        class="p-2 rounded-lg bg-white dark:bg-gray-800 mr-2"
+        @click="editor.fitView()"
+      >
+        <v-remixicon name="riFullscreenLine" />
+      </button>
+      <div class="rounded-lg bg-white dark:bg-gray-800 inline-block">
+        <button
+          v-tooltip.group="t('workflow.editor.zoomOut')"
+          class="p-2 rounded-lg relative z-10"
+          @click="editor.zoomOut()"
+        >
+          <v-remixicon name="riSubtractLine" />
+        </button>
+        <hr class="h-6 border-r inline-block" />
+        <button
+          v-tooltip.group="t('workflow.editor.zoomIn')"
+          class="p-2 rounded-lg"
+          @click="editor.zoomIn()"
+        >
+          <v-remixicon name="riAddLine" />
+        </button>
+      </div>
+      <editor-search-blocks :editor="editor" />
+    </div>
+    <template v-for="(node, name) in nodeTypes" :key="name" #[name]="nodeProps">
+      <component
+        :is="node"
+        v-bind="nodeProps"
+        @delete="deleteBlock"
+        @edit="editBlock(nodeProps, $event)"
+        @update="updateBlockData(nodeProps.id, $event)"
+      />
+    </template>
+  </vue-flow>
+</template>
+<script setup>
+import { onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { VueFlow, Background, MiniMap, useVueFlow } from '@braks/vue-flow';
+import cloneDeep from 'lodash.clonedeep';
+import { tasks, categories } from '@/utils/shared';
+import EditorSearchBlocks from './editor/EditorSearchBlocks.vue';
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: 'editor',
+  },
+  data: {
+    type: Object,
+    default: () => ({
+      x: 0,
+      y: 0,
+      zoom: 0,
+      nodes: [],
+      edges: [],
+    }),
+  },
+});
+const emit = defineEmits(['edit', 'init', 'update:node', 'delete:node']);
+
+const isMac = navigator.appVersion.indexOf('Mac') !== -1;
+const blockComponents = require.context('@/components/block', false, /\.vue$/);
+const nodeTypes = blockComponents.keys().reduce((acc, key) => {
+  const name = key.replace(/(.\/)|\.vue$/g, '');
+  acc[`node-${name}`] = blockComponents(key).default;
+
+  return acc;
+}, {});
+
+const { t } = useI18n();
+const editor = useVueFlow({
+  id: props.id,
+  minZoom: 0.4,
+  defaultZoom: 0,
+  deleteKeyCode: 'Delete',
+  multiSelectionKeyCode: isMac ? 'Meta' : 'Control',
+  ...props.data,
+});
+editor.onConnect((params) => {
+  params.class = `source-${params.sourceHandle} target-${params.targetHandle}`;
+  editor.addEdges([params]);
+});
+
+function minimapNodeClassName({ label }) {
+  const { category } = tasks[label];
+  const { color } = categories[category];
+
+  return color;
+}
+function updateBlockData(nodeId, data) {
+  const node = editor.getNode.value(nodeId);
+  Object.assign(node.data, data);
+  emit('update:node', node);
+}
+function editBlock({ id, label, data }, additionalData = {}) {
+  emit('edit', {
+    id: label,
+    blockId: id,
+    data: cloneDeep(data),
+    ...additionalData,
+  });
+}
+function deleteBlock(nodeId) {
+  editor.removeNodes([nodeId]);
+  emit('delete:node', nodeId);
+}
+
+onMounted(() => {
+  editor.setTransform({
+    x: props.data.x || 0,
+    y: props.data.y || 0,
+    zoom: props.data.zoom || 1,
+  });
+
+  emit('init', editor);
+});
+</script>
+<style>
+@import '@braks/vue-flow/dist/style.css';
+@import '@braks/vue-flow/dist/theme-default.css';
+</style>

+ 5 - 5
src/components/newtab/workflow/WorkflowSettings.vue

@@ -35,6 +35,7 @@
 <script setup>
 <script setup>
 import { onMounted, ref, reactive, watch } from 'vue';
 import { onMounted, ref, reactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
+import cloneDeep from 'lodash.clonedeep';
 import { debounce } from '@/utils/helper';
 import { debounce } from '@/utils/helper';
 import SettingsTable from './settings/SettingsTable.vue';
 import SettingsTable from './settings/SettingsTable.vue';
 import SettingsBlocks from './settings/SettingsBlocks.vue';
 import SettingsBlocks from './settings/SettingsBlocks.vue';
@@ -81,16 +82,15 @@ const settings = reactive({
 
 
 watch(
 watch(
   settings,
   settings,
-  debounce((newSettings) => {
-    emit('update', {
-      settings: newSettings,
-    });
+  debounce(() => {
+    emit('update', { settings });
   }, 500),
   }, 500),
   { deep: true }
   { deep: true }
 );
 );
 
 
 onMounted(() => {
 onMounted(() => {
-  Object.assign(settings, props.workflow.settings);
+  const copySettings = cloneDeep(props.workflow.settings);
+  Object.assign(settings, copySettings);
 });
 });
 </script>
 </script>
 <style>
 <style>

+ 7 - 15
src/components/newtab/workflow/WorkflowShare.vue

@@ -1,6 +1,6 @@
 <template>
 <template>
   <ui-card class="w-full max-w-2xl share-workflow overflow-auto scroll">
   <ui-card class="w-full max-w-2xl share-workflow overflow-auto scroll">
-    <template v-if="!store.state.user?.username">
+    <template v-if="!userStore.user?.username">
       <div class="flex items-center mb-12">
       <div class="flex items-center mb-12">
         <p>{{ t('workflow.share.title') }}</p>
         <p>{{ t('workflow.share.title') }}</p>
         <div class="flex-grow"></div>
         <div class="flex-grow"></div>
@@ -173,10 +173,11 @@
 </template>
 </template>
 <script setup>
 <script setup>
 import { reactive, watch } from 'vue';
 import { reactive, watch } from 'vue';
-import { useStore } from 'vuex';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import { useToast } from 'vue-toastification';
 import { fetchApi } from '@/utils/api';
 import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
 import { workflowCategories } from '@/utils/shared';
 import { workflowCategories } from '@/utils/shared';
 import { parseJSON, debounce } from '@/utils/helper';
 import { parseJSON, debounce } from '@/utils/helper';
 import { convertWorkflow } from '@/utils/workflowData';
 import { convertWorkflow } from '@/utils/workflowData';
@@ -193,7 +194,8 @@ const emit = defineEmits(['close', 'publish', 'change']);
 
 
 const { t } = useI18n();
 const { t } = useI18n();
 const toast = useToast();
 const toast = useToast();
-const store = useStore();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
 
 
 const menuItems = [
 const menuItems = [
   { id: 'bold', name: 'Bold', icon: 'riBold', action: 'toggleBold' },
   { id: 'bold', name: 'Bold', icon: 'riBold', action: 'toggleBold' },
@@ -224,13 +226,6 @@ async function publishWorkflow() {
 
 
     delete workflow.extVersion;
     delete workflow.extVersion;
 
 
-    const nodes = workflow.drawflow?.drawflow.Home.data;
-    Object.keys(nodes).forEach((nodeId) => {
-      if (nodes[nodeId].name !== 'loop-data') return;
-
-      nodes[nodeId].data.loopData = '';
-    });
-
     const response = await fetchApi('/me/workflows/shared', {
     const response = await fetchApi('/me/workflows/shared', {
       method: 'POST',
       method: 'POST',
       body: JSON.stringify({ workflow }),
       body: JSON.stringify({ workflow }),
@@ -246,13 +241,10 @@ async function publishWorkflow() {
 
 
     workflow.drawflow = props.workflow.drawflow;
     workflow.drawflow = props.workflow.drawflow;
 
 
-    store.commit('updateStateNested', {
-      path: `sharedWorkflows.${workflow.id}`,
-      value: workflow,
-    });
+    workflowStore.shared[workflow.id] = workflow;
     sessionStorage.setItem(
     sessionStorage.setItem(
       'shared-workflows',
       'shared-workflows',
-      JSON.stringify(store.state.sharedWorkflows)
+      JSON.stringify(workflowStore.shared)
     );
     );
 
 
     state.isPublishing = false;
     state.isPublishing = false;

+ 17 - 5
src/components/newtab/workflow/edit/EditConditions.vue

@@ -68,6 +68,7 @@
       item-key="id"
       item-key="id"
       tag="ui-list"
       tag="ui-list"
       class="space-y-1"
       class="space-y-1"
+      @end="onEnd"
     >
     >
       <template #item="{ element, index }">
       <template #item="{ element, index }">
         <ui-list-item class="group cursor-move">
         <ui-list-item class="group cursor-move">
@@ -85,7 +86,7 @@
             name="riDeleteBin7Line"
             name="riDeleteBin7Line"
             size="20"
             size="20"
             class="ml-2 -mr-1 cursor-pointer"
             class="ml-2 -mr-1 cursor-pointer"
-            @click="deleteCondition(index)"
+            @click="deleteCondition(index, element.id)"
           />
           />
         </ui-list-item>
         </ui-list-item>
       </template>
       </template>
@@ -124,6 +125,7 @@ import { ref, watch, onMounted, shallowReactive } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import { nanoid } from 'nanoid';
 import { nanoid } from 'nanoid';
 import Draggable from 'vuedraggable';
 import Draggable from 'vuedraggable';
+import { sleep } from '@/utils/helper';
 import emitter from '@/lib/mitt';
 import emitter from '@/lib/mitt';
 import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
 import SharedConditionBuilder from '@/components/newtab/shared/SharedConditionBuilder/index.vue';
 
 
@@ -132,6 +134,10 @@ const props = defineProps({
     type: Object,
     type: Object,
     default: () => ({}),
     default: () => ({}),
   },
   },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
   blockId: {
   blockId: {
     type: String,
     type: String,
     default: '',
     default: '',
@@ -174,12 +180,13 @@ function addCondition() {
     conditions: [],
     conditions: [],
   });
   });
 }
 }
-function deleteCondition(index) {
+function deleteCondition(index, id) {
   conditions.value.splice(index, 1);
   conditions.value.splice(index, 1);
 
 
-  emitter.emit('conditions-block:delete', {
-    index,
-    id: props.blockId,
+  props.editor.removeEdges((edges) => {
+    return edges.filter(
+      (edge) => edge.sourceHandle === `${props.blockId}-output-${id}`
+    );
   });
   });
 }
 }
 function refreshConnections() {
 function refreshConnections() {
@@ -190,6 +197,11 @@ function refreshConnections() {
 function updateData(value) {
 function updateData(value) {
   emit('update:data', { ...props.data, ...value });
   emit('update:data', { ...props.data, ...value });
 }
 }
+async function onEnd() {
+  props.editor.addSelectedNodes([]);
+  await sleep(1000);
+  props.editor.addSelectedNodes([props.editor.getNode.value(props.blockId)]);
+}
 
 
 watch(
 watch(
   conditions,
   conditions,

+ 432 - 0
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -0,0 +1,432 @@
+<template>
+  <ui-card
+    v-if="!workflow.isProtected"
+    padding="p-1"
+    class="flex items-center pointer-events-auto"
+  >
+    <ui-popover>
+      <template #trigger>
+        <button
+          v-tooltip.group="t('workflow.host.title')"
+          class="hoverable p-2 rounded-lg"
+        >
+          <v-remixicon
+            :class="{ 'text-primary': hosted }"
+            name="riBaseStationLine"
+          />
+        </button>
+      </template>
+      <div :class="{ 'text-center': state.isUploadingHost }" class="w-64">
+        <div class="flex items-center text-gray-600 dark:text-gray-200">
+          <p>
+            {{ t('workflow.host.set') }}
+          </p>
+          <a
+            :title="t('common.docs')"
+            href="https://docs.automa.site/guide/host-workflow.html"
+            target="_blank"
+            class="ml-1"
+          >
+            <v-remixicon name="riInformationLine" size="20" />
+          </a>
+          <div class="flex-grow"></div>
+          <ui-spinner v-if="state.isUploadingHost" color="text-accent" />
+          <ui-switch
+            v-else
+            :model-value="Boolean(hosted)"
+            @change="setAsHostWorkflow"
+          />
+        </div>
+        <transition-expand>
+          <ui-input
+            v-if="hosted"
+            v-tooltip:bottom="t('workflow.host.id')"
+            :model-value="hosted.hostId"
+            prepend-icon="riLinkM"
+            readonly
+            class="mt-4 block w-full"
+            @click="$event.target.select()"
+          />
+        </transition-expand>
+      </div>
+    </ui-popover>
+    <button
+      v-tooltip.group="t('workflow.share.title')"
+      :class="{ 'text-primary': shared }"
+      class="hoverable p-2 rounded-lg"
+      @click="shareWorkflow"
+    >
+      <v-remixicon name="riShareLine" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4 pointer-events-auto">
+    <button
+      v-for="item in modalActions"
+      :key="item.id"
+      v-tooltip.group="item.name"
+      class="hoverable p-2 rounded-lg"
+      @click="$emit('modal', item.id)"
+    >
+      <v-remixicon :name="item.icon" />
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4 flex items-center pointer-events-auto">
+    <button
+      v-if="!workflow.isDisabled"
+      v-tooltip.group="
+        `${t('common.execute')} (${
+          shortcuts['editor:execute-workflow'].readable
+        })`
+      "
+      class="hoverable p-2 rounded-lg"
+      @click="executeWorkflow"
+    >
+      <v-remixicon name="riPlayLine" />
+    </button>
+    <button
+      v-else
+      v-tooltip="t('workflow.clickToEnable')"
+      class="p-2"
+      @click="$emit('update', { isDisabled: false })"
+    >
+      {{ t('common.disabled') }}
+    </button>
+  </ui-card>
+  <ui-card padding="p-1 ml-4 space-x-1 pointer-events-auto">
+    <ui-popover>
+      <template #trigger>
+        <button class="rounded-lg p-2 hoverable">
+          <v-remixicon name="riMore2Line" />
+        </button>
+      </template>
+      <ui-list class="w-36">
+        <ui-list-item
+          class="cursor-pointer"
+          @click="$emit('update', { isDisabled: !workflow.isDisabled })"
+        >
+          <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+          {{ t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`) }}
+        </ui-list-item>
+        <ui-list-item
+          v-for="item in moreActions"
+          :key="item.id"
+          v-close-popover
+          class="cursor-pointer"
+          @click="item.action"
+        >
+          <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+          {{ item.name }}
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
+    <ui-button
+      :title="shortcuts['editor:save'].readable"
+      variant="accent"
+      class="relative"
+      @click="saveWorkflow"
+    >
+      <span
+        v-if="isDataChanged"
+        class="flex h-3 w-3 absolute top-0 left-0 -ml-1 -mt-1"
+      >
+        <span
+          class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"
+        ></span>
+        <span
+          class="relative inline-flex rounded-full h-3 w-3 bg-blue-600"
+        ></span>
+      </span>
+      <v-remixicon name="riSaveLine" class="mr-2 -ml-1 my-1" />
+      {{ t('common.save') }}
+    </ui-button>
+  </ui-card>
+  <ui-modal v-model="renameState.showModal" title="Workflow">
+    <ui-input
+      v-model="renameState.name"
+      :placeholder="t('common.name')"
+      autofocus
+      class="w-full mb-4"
+      @keyup.enter="renameWorkflow"
+    />
+    <ui-textarea
+      v-model="renameState.description"
+      :placeholder="t('common.description')"
+      height="165px"
+      class="w-full dark:text-gray-200"
+      max="300"
+      style="min-height: 140px"
+    />
+    <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+      {{ renameState.description.length }}/300
+    </p>
+    <div class="space-x-2 flex">
+      <ui-button class="w-full" @click="clearRenameModal">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" class="w-full" @click="renameWorkflow">
+        {{ t('common.update') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+import { reactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
+import { sendMessage } from '@/utils/message';
+import { fetchApi } from '@/utils/api';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useDialog } from '@/composable/dialog';
+import { useGroupTooltip } from '@/composable/groupTooltip';
+import { useShortcut, getShortcut } from '@/composable/shortcut';
+import { parseJSON } from '@/utils/helper';
+import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
+import workflowTrigger from '@/utils/workflowTrigger';
+
+const props = defineProps({
+  isDataChanged: {
+    type: Boolean,
+    default: false,
+  },
+  workflow: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['modal', 'save', 'update']);
+
+useGroupTooltip();
+
+const { t } = useI18n();
+const toast = useToast();
+const router = useRouter();
+const dialog = useDialog();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
+const shortcuts = useShortcut(
+  [
+    /* eslint-disable-next-line */
+    getShortcut('editor:save', saveWorkflow),
+    getShortcut('editor:execute-workflow', 'execute'),
+  ],
+  ({ data }) => {
+    emit(data);
+  }
+);
+
+const state = reactive({
+  isUploadingHost: false,
+});
+const renameState = reactive({
+  name: '',
+  description: '',
+  showModal: false,
+});
+
+const shared = computed(() =>
+  workflowStore.getById('shared', props.workflow.id)
+);
+const hosted = computed(() =>
+  workflowStore.getById('userHosted', props.workflow.id)
+);
+
+function executeWorkflow() {
+  sendMessage(
+    'workflow:execute',
+    {
+      ...props.workflow,
+      isTesting: props.isDataChanged,
+    },
+    'background'
+  );
+}
+async function setAsHostWorkflow(isHost) {
+  if (!userStore.user) {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+    return;
+  }
+
+  state.isUploadingHost = true;
+
+  try {
+    let url = '/me/workflows';
+    let payload = {};
+
+    if (isHost) {
+      const workflowPaylod = convertWorkflow(props.workflow, ['id']);
+      workflowPaylod.drawflow = parseJSON(
+        props.workflow.drawflow,
+        props.workflow.drawflow
+      );
+      delete workflowPaylod.extVersion;
+
+      url += `/host`;
+      payload = {
+        method: 'POST',
+        body: JSON.stringify({
+          workflow: workflowPaylod,
+        }),
+      };
+    } else {
+      url += `?id=${props.workflow.id}&type=host`;
+      payload.method = 'DELETE';
+    }
+
+    const response = await fetchApi(url, payload);
+    const result = await response.json();
+
+    if (!response.ok) {
+      const error = new Error(result.message);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    if (isHost) {
+      workflowStore.userHosted[props.workflow.id] = result;
+    } else {
+      delete workflowStore.userHosted[props.workflow.id];
+    }
+
+    const userWorkflows = parseJSON('user-workflows', {
+      backup: [],
+      hosted: {},
+    });
+    userWorkflows.hosted = workflowStore.userHosted;
+    sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
+
+    state.isUploadingHost = false;
+  } catch (error) {
+    console.error(error);
+    state.isUploadingHost = false;
+    toast.error(error.message);
+  }
+}
+function shareWorkflow() {
+  if (shared.value) {
+    router.push(`/${props.workflow.id}/shared`);
+    return;
+  }
+
+  if (userStore.user) {
+    emit('modal', 'workflow-share');
+  } else {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+  }
+}
+function clearRenameModal() {
+  Object.assign(renameState, {
+    id: '',
+    name: '',
+    description: '',
+    showModal: false,
+  });
+}
+function initRenameWorkflow() {
+  Object.assign(renameState, {
+    showModal: true,
+    name: `${props.workflow.name}`,
+    description: `${props.workflow.description}`,
+  });
+}
+function renameWorkflow() {
+  workflowStore.updateWorkflow({
+    location: 'local',
+    id: props.workflow.id,
+    data: {
+      name: renameState.name,
+      description: renameState.description,
+    },
+  });
+  clearRenameModal();
+}
+function deleteWorkflow() {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: props.workflow.name }),
+    onConfirm: async () => {
+      await workflowStore.deleteWorkflow(props.workflow.id, 'local');
+      router.replace('/');
+    },
+  });
+}
+async function saveWorkflow() {
+  try {
+    const flow = props.editor.toObject();
+    flow.edges = flow.edges.map((edge) => {
+      delete edge.sourceNode;
+      delete edge.targetNode;
+
+      return edge;
+    });
+
+    const triggerBlock = flow.nodes.find((node) => node.label === 'trigger');
+    if (!triggerBlock) {
+      toast.error(t('message.noTriggerBlock'));
+      return;
+    }
+
+    workflowStore.updateWorkflow({
+      location: 'local',
+      id: props.workflow.id,
+      data: {
+        drawflow: flow,
+        trigger: triggerBlock,
+      },
+    });
+
+    workflowTrigger.register(props.workflow.id, triggerBlock);
+    emit('save');
+  } catch (error) {
+    console.error(error);
+  }
+}
+const modalActions = [
+  {
+    id: 'table',
+    name: t('workflow.table.title'),
+    icon: 'riTable2',
+  },
+  {
+    id: 'global-data',
+    name: t('common.globalData'),
+    icon: 'riDatabase2Line',
+  },
+  {
+    id: 'settings',
+    name: t('common.settings'),
+    icon: 'riSettings3Line',
+  },
+];
+const moreActions = [
+  {
+    id: 'export',
+    name: t('common.export'),
+    icon: 'riDownloadLine',
+    action: () => exportWorkflow(props.workflow),
+  },
+  {
+    id: 'rename',
+    icon: 'riPencilLine',
+    name: t('common.rename'),
+    action: initRenameWorkflow,
+  },
+  {
+    id: 'delete',
+    action: deleteWorkflow,
+    name: t('common.delete'),
+    icon: 'riDeleteBin7Line',
+  },
+];
+</script>

+ 53 - 0
src/components/newtab/workflow/editor/EditorLogs.vue

@@ -0,0 +1,53 @@
+<template>
+  <div
+    v-if="(!logs || logs.length === 0) && workflowStates.length === 0"
+    class="text-center"
+  >
+    <img src="@/assets/svg/files-and-folder.svg" class="mx-auto max-w-sm" />
+    <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
+  </div>
+  <shared-logs-table
+    :logs="logs"
+    :running="workflowStates"
+    hide-select
+    class="w-full"
+  >
+    <template #item-append="{ log: itemLog }">
+      <td class="text-right">
+        <v-remixicon
+          name="riDeleteBin7Line"
+          class="inline-block text-red-500 cursor-pointer dark:text-red-400"
+          @click="deleteLog(itemLog.id)"
+        />
+      </td>
+    </template>
+  </shared-logs-table>
+</template>
+<script setup>
+import { useI18n } from 'vue-i18n';
+import dbLogs from '@/db/logs';
+import { useLiveQuery } from '@/composable/liveQuery';
+import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+
+const props = defineProps({
+  workflowId: {
+    type: String,
+    default: '',
+  },
+  workflowStates: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const { t } = useI18n();
+
+const logs = useLiveQuery(() =>
+  dbLogs.items
+    .where('workflowId')
+    .equals(props.workflowId)
+    .reverse()
+    .limit(15)
+    .sortBy('endedAt')
+);
+</script>

+ 39 - 35
src/components/newtab/workflow/WorkflowBuilderSearchBlocks.vue → src/components/newtab/workflow/editor/EditorSearchBlocks.vue

@@ -67,11 +67,13 @@ const props = defineProps({
 const { t } = useI18n();
 const { t } = useI18n();
 
 
 const initialState = {
 const initialState = {
-  zoom: 1,
   rectX: 0,
   rectX: 0,
   rectY: 0,
   rectY: 0,
-  canvasX: 0,
-  canvasY: 0,
+  position: {
+    x: 0,
+    y: 0,
+    zoom: 1,
+  },
 };
 };
 
 
 const autocompleteEl = ref(null);
 const autocompleteEl = ref(null);
@@ -103,25 +105,22 @@ function toggleActiveSearch() {
   }
   }
 }
 }
 function extractBlocks() {
 function extractBlocks() {
-  const { width, height } = props.editor.container.getBoundingClientRect();
+  const editorContainer = document.querySelector('.vue-flow');
+  editorContainer.classList.add('add-transition');
+  const { width, height } = editorContainer.getBoundingClientRect();
+
   initialState.rectX = width / 2;
   initialState.rectX = width / 2;
   initialState.rectY = height / 2;
   initialState.rectY = height / 2;
-  initialState.zoom = props.editor.zoom;
-  initialState.canvasX = props.editor.canvas_x;
-  initialState.canvasY = props.editor.canvas_y;
+  initialState.position = props.editor.getTransform();
 
 
-  const { drawflow } = props.editor.export();
-  state.autocompleteItems = Object.values(drawflow.Home.data).map(
-    ({ id, name, data, pos_x, pos_y }) => ({
+  state.autocompleteItems = props.editor.getNodes.value.map(
+    ({ computedPosition, id, data, label }) => ({
       id,
       id,
-      pos_x,
-      pos_y,
+      position: computedPosition,
       description: data.description || '',
       description: data.description || '',
-      name: t(`workflow.blocks.${name}.name`),
+      name: t(`workflow.blocks.${label}.name`),
     })
     })
   );
   );
-
-  props.editor.precanvas.style.transition = 'transform 300ms ease';
 }
 }
 function clearHighlightedNodes() {
 function clearHighlightedNodes() {
   document.querySelectorAll('.search-select-node').forEach((el) => {
   document.querySelectorAll('.search-select-node').forEach((el) => {
@@ -130,8 +129,7 @@ function clearHighlightedNodes() {
 }
 }
 function clearState() {
 function clearState() {
   if (!state.selected) {
   if (!state.selected) {
-    const { canvasX, canvasY, zoom } = initialState;
-    props.editor.translate_to(canvasX, canvasY, zoom);
+    props.editor.setTransform(initialState.position);
   }
   }
 
 
   state.query = '';
   state.query = '';
@@ -139,55 +137,61 @@ function clearState() {
   state.selected = false;
   state.selected = false;
 
 
   Object.assign(initialState, {
   Object.assign(initialState, {
-    zoom: 1,
     rectX: 0,
     rectX: 0,
     rectY: 0,
     rectY: 0,
-    canvasX: 0,
-    canvasY: 0,
+    position: {
+      x: 0,
+      y: 0,
+      zoom: 1,
+    },
   });
   });
 
 
   autocompleteEl.value.state.showPopover = false;
   autocompleteEl.value.state.showPopover = false;
   clearHighlightedNodes();
   clearHighlightedNodes();
 
 
   setTimeout(() => {
   setTimeout(() => {
-    props.editor.precanvas.style.transition = '';
+    const editorContainer = document.querySelector('.vue-flow');
+    editorContainer.classList.remove('add-transition');
   }, 500);
   }, 500);
 }
 }
 function blurInput() {
 function blurInput() {
   document.querySelector('#search-blocks')?.blur();
   document.querySelector('#search-blocks')?.blur();
 }
 }
 function onSelectItem({ item }) {
 function onSelectItem({ item }) {
-  if (props.editor.zoom !== 1) {
-    /* eslint-disable-next-line */
-    props.editor.zoom = 1;
-    props.editor.zoom_refresh();
-  }
+  const { x, y } = item.position;
+  const { rectX, rectY } = initialState;
 
 
   clearHighlightedNodes();
   clearHighlightedNodes();
   document
   document
-    .querySelector(`#node-${item.id}`)
+    .querySelector(`[data-id="${item.id}"]`)
     ?.classList.add('search-select-node');
     ?.classList.add('search-select-node');
 
 
-  const { rectX, rectY } = initialState;
-  props.editor.translate_to(
-    -(item.pos_x - rectX),
-    -(item.pos_y - rectY),
-    props.editor.zoom
-  );
+  props.editor.setTransform({
+    zoom: 1,
+    x: -(x - rectX),
+    y: -(y - rectY),
+  });
 }
 }
 function onItemSelected(event) {
 function onItemSelected(event) {
   state.selected = true;
   state.selected = true;
+
+  const node = props.editor.getNode.value(event.item.id);
+  props.editor.addSelectedNodes([node]);
+
   onSelectItem(event);
   onSelectItem(event);
   blurInput();
   blurInput();
 }
 }
 </script>
 </script>
 <style scoped>
 <style scoped>
 input {
 input {
-  transition: width 250ms ease;
+  transition: width 300ms ease;
 }
 }
 </style>
 </style>
 <style>
 <style>
-.search-select-node .drawflow_content_node {
+.search-select-node > div {
   @apply ring-4;
   @apply ring-4;
 }
 }
+.vue-flow.add-transition .vue-flow__transformationpane {
+  transition: transform 250ms ease;
+}
 </style>
 </style>

+ 70 - 0
src/components/newtab/workflows/WorkflowsHosted.vue

@@ -0,0 +1,70 @@
+<template>
+  <shared-card
+    v-for="workflow in workflows"
+    :key="workflow.hostId"
+    :data="workflow"
+    :menu="menu"
+    @execute="executeWorkflow(workflow)"
+    @click="$router.push(`/workflows/${$event.hostId}/host`)"
+    @menuSelected="deleteWorkflow(workflow)"
+  />
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useWorkflowStore } from '@/stores/workflow';
+import { useDialog } from '@/composable/dialog';
+import { arraySorter } from '@/utils/helper';
+import { cleanWorkflowTriggers } from '@/utils/workflowTrigger';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+});
+
+const { t } = useI18n();
+const dialog = useDialog();
+const workflowStore = useWorkflowStore();
+
+const menu = [
+  { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
+];
+
+const workflows = computed(() => {
+  const filtered = Object.values(workflowStore.hosted).filter(({ name }) =>
+    name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase())
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+
+async function deleteWorkflow(workflow) {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: workflow.name }),
+    onConfirm: async () => {
+      try {
+        delete workflowStore.hosted[workflow.hostId];
+        await cleanWorkflowTriggers(workflow.hostId);
+      } catch (error) {
+        console.error(error);
+      }
+    },
+  });
+}
+</script>

+ 393 - 0
src/components/newtab/workflows/WorkflowsLocal.vue

@@ -0,0 +1,393 @@
+<template>
+  <div v-if="workflowStore.local.length === 0" class="py-12 flex items-center">
+    <img src="@/assets/svg/alien.svg" class="w-96" />
+    <div class="ml-4">
+      <h1 class="text-2xl font-semibold max-w-md mb-6">
+        {{ t('message.empty') }}
+      </h1>
+      <ui-button variant="accent" @click="newWorkflow">
+        {{ t('workflow.new') }}
+      </ui-button>
+    </div>
+  </div>
+  <template v-else>
+    <div class="workflows-container">
+      <shared-card
+        v-for="workflow in workflows"
+        :key="workflow.id"
+        :data="workflow"
+        :data-workflow="workflow.id"
+        draggable="true"
+        class="cursor-default select-none ring-accent local-workflow"
+        @dragstart="onDragStart"
+        @click="$router.push(`/workflows/${$event.id}`)"
+      >
+        <template #header>
+          <div class="flex items-center mb-4">
+            <template v-if="!workflow.isDisabled">
+              <ui-img
+                v-if="workflow.icon.startsWith('http')"
+                :src="workflow.icon"
+                class="rounded-lg overflow-hidden"
+                style="height: 40px; width: 40px"
+                alt="Can not display"
+              />
+              <span
+                v-else
+                class="p-2 rounded-lg bg-box-transparent inline-block"
+              >
+                <v-remixicon :name="workflow.icon" />
+              </span>
+            </template>
+            <p v-else class="py-2">{{ t('common.disabled') }}</p>
+            <div class="flex-grow"></div>
+            <button
+              v-if="!workflow.isDisabled"
+              class="invisible group-hover:visible"
+              @click="executeWorkflow(workflow)"
+            >
+              <v-remixicon name="riPlayLine" />
+            </button>
+            <ui-popover class="h-6 ml-2">
+              <template #trigger>
+                <button>
+                  <v-remixicon name="riMoreLine" />
+                </button>
+              </template>
+              <ui-list class="space-y-1" style="min-width: 150px">
+                <ui-list-item
+                  class="cursor-pointer"
+                  @click="toggleDisableWorkflow(workflow)"
+                >
+                  <v-remixicon name="riToggleLine" class="mr-2 -ml-1" />
+                  <span class="capitalize">
+                    {{
+                      t(`common.${workflow.isDisabled ? 'enable' : 'disable'}`)
+                    }}
+                  </span>
+                </ui-list-item>
+                <ui-list-item
+                  v-for="item in menu"
+                  :key="item.id"
+                  v-close-popover
+                  class="cursor-pointer"
+                  @click="item.action(workflow)"
+                >
+                  <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
+                  <span class="capitalize">{{ item.name }}</span>
+                </ui-list-item>
+              </ui-list>
+            </ui-popover>
+          </div>
+        </template>
+        <template #footer-content>
+          <v-remixicon
+            v-if="workflowStore.shared[workflow.id]"
+            v-tooltip:bottom.group="
+              t('workflow.share.sharedAs', {
+                name: workflowStore.shared[workflow.id]?.name.slice(0, 64),
+              })
+            "
+            name="riShareLine"
+            size="20"
+            class="ml-2"
+          />
+          <v-remixicon
+            v-if="workflowStore.hosted[workflow.id]"
+            v-tooltip:bottom.group="t('workflow.host.title')"
+            name="riBaseStationLine"
+            size="20"
+            class="ml-2"
+          />
+        </template>
+      </shared-card>
+    </div>
+    <div
+      v-if="workflows.length > 18"
+      class="flex items-center justify-between mt-8"
+    >
+      <div>
+        {{ t('components.pagination.text1') }}
+        <select v-model="pagination.perPage" class="p-1 rounded-md bg-input">
+          <option v-for="num in [18, 32, 64, 128]" :key="num" :value="num">
+            {{ num }}
+          </option>
+        </select>
+        {{
+          t('components.pagination.text2', {
+            count: workflows.length,
+          })
+        }}
+      </div>
+      <ui-pagination
+        v-model="pagination.currentPage"
+        :per-page="pagination.perPage"
+        :records="workflows.length"
+      />
+    </div>
+  </template>
+  <ui-modal v-model="renameState.show" title="Workflow">
+    <ui-input
+      v-model="renameState.name"
+      :placeholder="t('common.name')"
+      autofocus
+      class="w-full mb-4"
+      @keyup.enter="renameWorkflow"
+    />
+    <ui-textarea
+      v-model="renameState.description"
+      :placeholder="t('common.description')"
+      height="165px"
+      class="w-full dark:text-gray-200"
+      max="300"
+      style="min-height: 140px"
+    />
+    <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+      {{ renameState.description.length }}/300
+    </p>
+    <div class="space-x-2 flex">
+      <ui-button class="w-full" @click="clearRenameModal">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" class="w-full" @click="renameWorkflow">
+        {{ t('common.update') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+import { shallowReactive, computed, onMounted, onBeforeUnmount } from 'vue';
+import { useI18n } from 'vue-i18n';
+import SelectionArea from '@viselect/vanilla';
+import { useDialog } from '@/composable/dialog';
+import { useWorkflowStore } from '@/stores/workflow';
+import { arraySorter } from '@/utils/helper';
+import { exportWorkflow } from '@/utils/workflowData';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  folderId: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+  perPage: {
+    type: Number,
+    default: 18,
+  },
+});
+
+const { t } = useI18n();
+const dialog = useDialog();
+const workflowStore = useWorkflowStore();
+
+const state = shallowReactive({
+  selectedWorkflows: [],
+});
+const renameState = shallowReactive({
+  id: '',
+  name: '',
+  show: false,
+  description: '',
+});
+const pagination = shallowReactive({
+  currentPage: 1,
+  perPage: +`${props.perPage}` || 18,
+});
+
+const selection = new SelectionArea({
+  container: '.workflows-list',
+  startareas: ['.workflows-list'],
+  boundaries: ['.workflows-list'],
+  selectables: ['.local-workflow'],
+});
+selection
+  .on('beforestart', ({ event }) => {
+    return (
+      event.target.tagName !== 'INPUT' &&
+      !event.target.closest('.local-workflow')
+    );
+  })
+  .on('start', () => {
+    /* eslint-disable-next-line */
+  clearSelectedWorkflows();
+  })
+  .on('move', (event) => {
+    event.store.changed.added.forEach((el) => {
+      el.classList.add('ring-2');
+    });
+    event.store.changed.removed.forEach((el) => {
+      el.classList.remove('ring-2');
+    });
+  })
+  .on('stop', (event) => {
+    state.selectedWorkflows = event.store.selected.map(
+      (el) => el.dataset?.workflow
+    );
+  });
+
+const filteredWorkflows = computed(() => {
+  const filtered = workflowStore.local.filter(
+    ({ name, folderId }) =>
+      name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase()) &&
+      (!props.activeFolder || props.activeFolder === folderId)
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+const workflows = computed(() =>
+  filteredWorkflows.value.slice(
+    (pagination.currentPage - 1) * pagination.perPage,
+    pagination.currentPage * pagination.perPage
+  )
+);
+
+function toggleDisableWorkflow({ id, isDisabled }) {
+  workflowStore.updateWorkflow({
+    id,
+    location: 'local',
+    data: {
+      isDisabled: !isDisabled,
+    },
+  });
+}
+function clearRenameModal() {
+  Object.assign(renameState, {
+    id: '',
+    name: '',
+    show: false,
+    description: '',
+  });
+}
+function initRenameWorkflow({ name, description, id }) {
+  Object.assign(renameState, {
+    id,
+    name,
+    show: true,
+    description,
+  });
+}
+function renameWorkflow() {
+  workflowStore.updateWorkflow({
+    location: 'local',
+    id: renameState.id,
+    data: {
+      name: renameState.name,
+      description: renameState.description,
+    },
+  });
+  clearRenameModal();
+}
+function deleteWorkflow({ name, id }) {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name }),
+    onConfirm: () => {
+      workflowStore.deleteWorkflow(id, 'local');
+    },
+  });
+}
+function deleteSelectedWorkflows({ target, key }) {
+  const excludeTags = ['INPUT', 'TEXTAREA', 'SELECT'];
+  if (
+    excludeTags.includes(target.tagName) ||
+    key !== 'Delete' ||
+    state.selectedWorkflows.length === 0
+  )
+    return;
+
+  if (state.selectedWorkflows.length === 1) {
+    const [workflowId] = state.selectedWorkflows;
+    const workflow = workflowStore.getById('local', workflowId);
+    deleteWorkflow(workflow);
+  } else {
+    dialog.confirm({
+      title: t('workflow.delete'),
+      okVariant: 'danger',
+      body: t('message.delete', {
+        name: `${state.selectedWorkflows.length} workflows`,
+      }),
+      onConfirm: async () => {
+        for (const workflowId of state.selectedWorkflows) {
+          await workflowStore.deleteWorkflow(workflowId, 'local');
+        }
+      },
+    });
+  }
+}
+function duplicateWorkflow(workflow) {
+  const copyWorkflow = { ...workflow, createdAt: Date.now() };
+  const delKeys = ['$id', 'data', 'id', 'isDisabled'];
+
+  delKeys.forEach((key) => {
+    delete copyWorkflow[key];
+  });
+
+  workflowStore.addWorkflow(copyWorkflow);
+}
+function onDragStart({ dataTransfer, target }) {
+  const payload = [...state.selectedWorkflows];
+
+  const targetId = target.dataset?.workflow;
+  if (targetId && !payload.includes(targetId)) payload.push(targetId);
+
+  dataTransfer.setData('workflows', JSON.stringify(payload));
+}
+function clearSelectedWorkflows() {
+  state.selectedWorkflows = [];
+
+  selection.getSelection().forEach((el) => {
+    el.classList.remove('ring-2');
+  });
+  selection.clearSelection();
+}
+
+const menu = [
+  {
+    id: 'duplicate',
+    name: t('common.duplicate'),
+    icon: 'riFileCopyLine',
+    action: duplicateWorkflow,
+  },
+  {
+    id: 'export',
+    name: t('common.export'),
+    icon: 'riDownloadLine',
+    action: exportWorkflow,
+  },
+  {
+    id: 'rename',
+    name: t('common.rename'),
+    icon: 'riPencilLine',
+    action: initRenameWorkflow,
+  },
+  {
+    id: 'delete',
+    name: t('common.delete'),
+    icon: 'riDeleteBin7Line',
+    action: deleteWorkflow,
+  },
+];
+
+onMounted(() => {
+  window.addEventListener('keydown', deleteSelectedWorkflows);
+});
+onBeforeUnmount(() => {
+  window.removeEventListener('keydown', deleteSelectedWorkflows);
+});
+</script>

+ 44 - 0
src/components/newtab/workflows/WorkflowsShared.vue

@@ -0,0 +1,44 @@
+<template>
+  <shared-card
+    v-for="workflow in workflows"
+    :key="workflow.id"
+    :data="workflow"
+    :show-details="false"
+    @execute="executeWorkflow(workflow)"
+    @click="$router.push(`/workflows/${$event.id}/shared`)"
+  />
+</template>
+<script setup>
+import { computed } from 'vue';
+import { useWorkflowStore } from '@/stores/workflow';
+import { arraySorter } from '@/utils/helper';
+import SharedCard from '@/components/newtab/shared/SharedCard.vue';
+
+const props = defineProps({
+  search: {
+    type: String,
+    default: '',
+  },
+  sort: {
+    type: Object,
+    default: () => ({
+      by: '',
+      order: '',
+    }),
+  },
+});
+
+const workflowStore = useWorkflowStore();
+
+const workflows = computed(() => {
+  const filtered = Object.values(workflowStore.shared).filter(({ name }) =>
+    name.toLocaleLowerCase().includes(props.search.toLocaleLowerCase())
+  );
+
+  return arraySorter({
+    data: filtered,
+    key: props.sort.by,
+    order: props.sort.order,
+  });
+});
+</script>

+ 5 - 13
src/components/ui/UiTable.vue

@@ -53,7 +53,7 @@
 </template>
 </template>
 <script setup>
 <script setup>
 import { reactive, computed, watch } from 'vue';
 import { reactive, computed, watch } from 'vue';
-import { isObject } from '@/utils/helper';
+import { isObject, arraySorter } from '@/utils/helper';
 
 
 const props = defineProps({
 const props = defineProps({
   headers: {
   headers: {
@@ -105,18 +105,10 @@ const filteredItems = computed(() => {
 const sortedItems = computed(() => {
 const sortedItems = computed(() => {
   if (sortState.id === '') return filteredItems.value;
   if (sortState.id === '') return filteredItems.value;
 
 
-  return filteredItems.value.slice().sort((a, b) => {
-    let comparison = 0;
-    const itemA = a[sortState.id];
-    const itemB = b[sortState.id];
-
-    if (itemA > itemB) {
-      comparison = 1;
-    } else if (itemA < itemB) {
-      comparison = -1;
-    }
-
-    return sortState.order === 'desc' ? comparison * -1 : comparison;
+  return arraySorter({
+    key: sortState.id,
+    order: sortState.order,
+    data: filteredItems.value,
   });
   });
 });
 });
 
 

+ 7 - 27
src/composable/editorBlock.js

@@ -1,40 +1,20 @@
-import { reactive, nextTick } from 'vue';
+import { reactive, onMounted } from 'vue';
 import { tasks, categories } from '@/utils/shared';
 import { tasks, categories } from '@/utils/shared';
 
 
-export function useEditorBlock(selector, editor) {
+export function useEditorBlock(label) {
   const block = reactive({
   const block = reactive({
-    id: '',
-    data: {},
     details: {},
     details: {},
     category: {},
     category: {},
-    retrieved: false,
-    containerEl: null,
   });
   });
 
 
-  nextTick(() => {
-    const rootElement = editor.rootElement || document;
-    const element = rootElement.querySelector(selector);
+  onMounted(() => {
+    if (!label) return;
 
 
-    if (block.id || !element) return;
+    const details = tasks[label];
 
 
-    block.containerEl = element.parentElement.parentElement;
-    block.id = block.containerEl.id.replace('node-', '');
-
-    if (block.id) {
-      const { name, data } = editor.getNodeFromId(block.id);
-      const details = tasks[name];
-
-      block.details = { id: name, ...details };
-      block.data = data || details.data;
-      block.category = categories[details.category];
-    }
-
-    setTimeout(() => {
-      editor.updateConnectionNodes(`node-${block.id}`);
-    }, 200);
+    block.details = { id: label, ...details };
+    block.category = categories[details.category];
   });
   });
 
 
-  block.retrieved = true;
-
   return block;
   return block;
 }
 }

+ 1 - 1
src/composable/shortcut.js

@@ -65,7 +65,7 @@ const customShortcut = parseJSON(localStorage.getItem('shortcuts', {})) || {};
 
 
 export const mapShortcuts = defu(customShortcut, defaultShortcut);
 export const mapShortcuts = defu(customShortcut, defaultShortcut);
 
 
-const os = navigator.appVersion.indexOf('Win') !== -1 ? 'win' : 'mac';
+const os = navigator.appVersion.indexOf('Mac') !== -1 ? 'mac' : 'win';
 export function getReadableShortcut(str) {
 export function getReadableShortcut(str) {
   const list = {
   const list = {
     option: {
     option: {

+ 27 - 0
src/lib/pinia.js

@@ -0,0 +1,27 @@
+import { markRaw, toRaw } from 'vue';
+import { createPinia } from 'pinia';
+import browser from 'webextension-polyfill';
+
+function saveToStoragePlugin({ store, options }) {
+  const newBrowser = markRaw(browser);
+
+  store.saveToStorage = (key) => {
+    const storageKey = options.storageMap[key];
+    if (!storageKey) return;
+
+    newBrowser.storage.local.set({ [storageKey]: toRaw(store[key]) });
+  };
+  store.$subscribe(({ events }, state) => {
+    if (!state.retrieved || !options.storageMap) return;
+
+    const storageKey = options.storageMap[events.key];
+    if (!storageKey) return;
+
+    console.log(storageKey, events.newValue);
+  });
+}
+
+const pinia = createPinia();
+pinia.use(saveToStoragePlugin);
+
+export default pinia;

+ 32 - 176
src/newtab/App.vue

@@ -1,8 +1,4 @@
 <template>
 <template>
-  <!-- <Head>
-    <link rel="icon" :href="icon" />
-  </Head> -->
-
   <template v-if="retrieved">
   <template v-if="retrieved">
     <app-sidebar />
     <app-sidebar />
     <main class="pl-16">
     <main class="pl-16">
@@ -53,51 +49,27 @@
   <div v-else class="py-8 text-center">
   <div v-else class="py-8 text-center">
     <ui-spinner color="text-accent" size="28" />
     <ui-spinner color="text-accent" size="28" />
   </div>
   </div>
-  <ui-card
-    v-if="modalState.show"
-    class="fixed bottom-8 right-8 shadow-2xl border-2 w-72 group"
-  >
-    <button
-      class="absolute bg-white shadow-md rounded-full -right-2 -top-2 transition scale-0 group-hover:scale-100"
-      @click="closeModal"
-    >
-      <v-remixicon class="text-gray-600" name="riCloseLine" />
-    </button>
-    <h2 class="font-semibold text-lg">
-      {{ activeModal.title }}
-    </h2>
-    <p class="mt-1 dark:text-gray-100 text-gray-700">
-      {{ activeModal.body }}
-    </p>
-    <div class="space-y-2 mt-4">
-      <ui-button
-        :href="activeModal.url"
-        tag="a"
-        target="_blank"
-        rel="noopener"
-        class="w-full block"
-        variant="accent"
-      >
-        {{ activeModal.button }}
-      </ui-button>
-    </div>
-  </ui-card>
+  <app-survey />
 </template>
 </template>
 <script setup>
 <script setup>
 import iconFirefox from '@/assets/svg/logoFirefox.svg';
 import iconFirefox from '@/assets/svg/logoFirefox.svg';
 import iconChrome from '@/assets/svg/logo.svg';
 import iconChrome from '@/assets/svg/logo.svg';
-import { ref, shallowReactive, computed } from 'vue';
-import { useStore } from 'vuex';
+import { ref } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import { compare } from 'compare-versions';
 import { compare } from 'compare-versions';
 import browser from 'webextension-polyfill';
 import browser from 'webextension-polyfill';
-import dbLogs from '@/db/logs';
+import { useStore } from '@/stores/main';
+import { useUserStore } from '@/stores/user';
+import { useFolderStore } from '@/stores/folder';
+import { useWorkflowStore } from '@/stores/workflow';
 import { useTheme } from '@/composable/theme';
 import { useTheme } from '@/composable/theme';
-import { loadLocaleMessages, setI18nLanguage } from '@/lib/vueI18n';
 import { parseJSON } from '@/utils/helper';
 import { parseJSON } from '@/utils/helper';
-import { fetchApi, getSharedWorkflows, getUserWorkflows } from '@/utils/api';
+import { loadLocaleMessages, setI18nLanguage } from '@/lib/vueI18n';
+import { getSharedWorkflows, getUserWorkflows } from '@/utils/api';
+import dbLogs from '@/db/logs';
 import dayjs from '@/lib/dayjs';
 import dayjs from '@/lib/dayjs';
 import Workflow from '@/models/workflow';
 import Workflow from '@/models/workflow';
+import AppSurvey from '@/components/newtab/app/AppSurvey.vue';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 import AppSidebar from '@/components/newtab/app/AppSidebar.vue';
 import dataMigration from '@/utils/dataMigration';
 import dataMigration from '@/utils/dataMigration';
 
 
@@ -116,122 +88,42 @@ document.head.appendChild(iconElement);
 const { t } = useI18n();
 const { t } = useI18n();
 const store = useStore();
 const store = useStore();
 const theme = useTheme();
 const theme = useTheme();
+const userStore = useUserStore();
+const folderStore = useFolderStore();
+const workflowStore = useWorkflowStore();
 
 
 theme.init();
 theme.init();
 
 
 const retrieved = ref(false);
 const retrieved = ref(false);
 const isUpdated = ref(false);
 const isUpdated = ref(false);
-const modalState = shallowReactive({
-  show: true,
-  type: 'survey',
-});
 
 
-const modalTypes = {
-  testimonial: {
-    title: 'Hi There 👋',
-    body: 'Thank you for using Automa, and if you have a great experience. Would you like to give us a testimonial?',
-    button: 'Give Testimonial',
-    url: 'https://testimonial.to/automa',
-  },
-  survey: {
-    title: "How do you think we're doing?",
-    body: 'To help us make Automa as best it can be, we need a few minutes of your time to get your feedback.',
-    button: 'Take Survey',
-    url: 'https://www.automa.site/survey',
-  },
-};
 const currentVersion = browser.runtime.getManifest().version;
 const currentVersion = browser.runtime.getManifest().version;
 const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
 const prevVersion = localStorage.getItem('ext-version') || '0.0.0';
 
 
-const activeModal = computed(() => modalTypes[modalState.type]);
-
-async function syncHostWorkflow(hosts) {
-  const hostIds = [];
-  const workflowHosts = hosts || store.state.workflowHosts;
-  const localWorkflowHost = Object.values(store.state.hostWorkflows);
-
-  Object.keys(workflowHosts).forEach((hostId) => {
-    const isItsOwn = localWorkflowHost.find((item) => item.hostId === hostId);
-
-    if (isItsOwn) return;
-
-    hostIds.push({ hostId, updatedAt: workflowHosts[hostId].updatedAt });
-  });
-
-  try {
-    await store.dispatch('fetchWorkflowHosts', hostIds);
-  } catch (error) {
-    console.error(error);
-  }
-}
 async function fetchUserData() {
 async function fetchUserData() {
   try {
   try {
-    const response = await fetchApi('/me');
-    const user = await response.json();
-
-    if (response.status !== 200) {
-      throw new Error(response.statusText);
-    }
-
-    const username = localStorage.getItem('username');
-
-    if (!user || username !== user.username) {
-      sessionStorage.removeItem('shared-workflows');
-      sessionStorage.removeItem('user-workflows');
-      sessionStorage.removeItem('backup-workflows');
-
-      await browser.storage.local.remove([
-        'backupIds',
-        'lastBackup',
-        'lastSync',
-      ]);
-
-      if (username !== user?.username) {
-        await Workflow.update({
-          where: ({ __id }) => __id !== null,
-          data: { __id: null },
-        });
-      }
-
-      if (!user) return;
-    }
-
-    store.commit('updateState', {
-      key: 'user',
-      value: user,
-    });
+    if (!userStore.user) return;
 
 
     const [sharedWorkflows, userWorkflows] = await Promise.allSettled([
     const [sharedWorkflows, userWorkflows] = await Promise.allSettled([
       getSharedWorkflows(),
       getSharedWorkflows(),
       getUserWorkflows(),
       getUserWorkflows(),
     ]);
     ]);
 
 
-    localStorage.setItem('username', user.username);
-
     if (sharedWorkflows.status === 'fulfilled') {
     if (sharedWorkflows.status === 'fulfilled') {
-      store.commit('updateState', {
-        key: 'sharedWorkflows',
-        value: sharedWorkflows.value,
-      });
+      workflowStore.shared = sharedWorkflows.value;
     }
     }
 
 
     if (userWorkflows.status === 'fulfilled') {
     if (userWorkflows.status === 'fulfilled') {
       const { backup, hosted } = userWorkflows.value;
       const { backup, hosted } = userWorkflows.value;
 
 
-      store.commit('updateState', {
-        key: 'hostWorkflows',
-        value: hosted || {},
-      });
+      workflowStore.userHosted = hosted || {};
 
 
       if (backup?.length > 0) {
       if (backup?.length > 0) {
         const { lastBackup } = browser.storage.local.get('lastBackup');
         const { lastBackup } = browser.storage.local.get('lastBackup');
         if (!lastBackup) {
         if (!lastBackup) {
           const backupIds = backup.map(({ id }) => id);
           const backupIds = backup.map(({ id }) => id);
 
 
-          store.commit('updateState', {
-            key: 'backupIds',
-            value: backupIds,
-          });
+          userStore.backupIds = backupIds;
           await browser.storage.local.set({
           await browser.storage.local.set({
             backupIds,
             backupIds,
             lastBackup: new Date().toISOString(),
             lastBackup: new Date().toISOString(),
@@ -244,17 +136,14 @@ async function fetchUserData() {
       }
       }
     }
     }
 
 
-    store.commit('updateState', {
-      key: 'userDataRetrieved',
-      value: true,
-    });
+    userStore.retrieved = true;
   } catch (error) {
   } catch (error) {
     console.error(error);
     console.error(error);
   }
   }
 }
 }
 /* eslint-disable-next-line */
 /* eslint-disable-next-line */
 function autoDeleteLogs() {
 function autoDeleteLogs() {
-  const deleteAfter = store.state.settings.deleteLogAfter;
+  const deleteAfter = store.settings.deleteLogAfter;
   if (deleteAfter === 'never') return;
   if (deleteAfter === 'never') return;
 
 
   const lastCheck =
   const lastCheck =
@@ -281,44 +170,14 @@ function autoDeleteLogs() {
       localStorage.setItem('checkDeleteLogs', Date.now());
       localStorage.setItem('checkDeleteLogs', Date.now());
     });
     });
 }
 }
-function closeModal() {
-  let value = true;
-
-  if (modalState.type === 'survey') {
-    value = new Date().toString();
-  }
-
-  modalState.show = false;
-  localStorage.setItem(`has-${modalState.type}`, value);
-}
-function checkModal() {
-  const survey = localStorage.getItem('has-survey');
-
-  if (!survey) return;
-
-  const daysDiff = dayjs().diff(survey, 'day');
-  const showTestimonial =
-    daysDiff >= 2 && !localStorage.getItem('has-testimonial');
-
-  if (showTestimonial) {
-    modalState.show = true;
-    modalState.type = 'testimonial';
-  } else {
-    modalState.show = false;
-  }
-}
 
 
 window.addEventListener('storage', ({ key, newValue }) => {
 window.addEventListener('storage', ({ key, newValue }) => {
   if (key !== 'workflowState') return;
   if (key !== 'workflowState') return;
 
 
   const states = parseJSON(newValue, {});
   const states = parseJSON(newValue, {});
-  store.commit('updateState', {
-    key: 'workflowState',
-    value: Object.values(states).filter(
-      ({ isDestroyed, parentState }) =>
-        !isDestroyed && !parentState?.isCollection
-    ),
-  });
+  workflowStore.states = Object.values(states).filter(
+    ({ isDestroyed }) => !isDestroyed
+  );
 });
 });
 
 
 (async () => {
 (async () => {
@@ -326,28 +185,25 @@ window.addEventListener('storage', ({ key, newValue }) => {
     const { isFirstTime } = await browser.storage.local.get('isFirstTime');
     const { isFirstTime } = await browser.storage.local.get('isFirstTime');
     isUpdated.value = !isFirstTime && compare(currentVersion, prevVersion, '>');
     isUpdated.value = !isFirstTime && compare(currentVersion, prevVersion, '>');
 
 
-    if (isFirstTime) {
-      modalState.show = false;
-      localStorage.setItem('has-testimonial', true);
-      localStorage.setItem('has-survey', Date.now());
-    } else {
-      checkModal();
-    }
-
     await Promise.allSettled([
     await Promise.allSettled([
-      store.dispatch('retrieve', ['workflows', 'collections', 'folders']),
-      store.dispatch('retrieveWorkflowState'),
+      folderStore.load(),
+      workflowStore.loadLocal(),
+      workflowStore.loadStates(),
     ]);
     ]);
 
 
-    await loadLocaleMessages(store.state.settings.locale, 'newtab');
-    await setI18nLanguage(store.state.settings.locale);
+    await loadLocaleMessages(store.settings.locale, 'newtab');
+    await setI18nLanguage(store.settings.locale);
 
 
     await dataMigration();
     await dataMigration();
+    await workflowStore.loadLocal();
 
 
     retrieved.value = true;
     retrieved.value = true;
+    workflowStore.retrieved = true;
 
 
+    await userStore.loadUser();
     await fetchUserData();
     await fetchUserData();
-    await syncHostWorkflow();
+    await workflowStore.syncHostedWorkflows();
+
     autoDeleteLogs();
     autoDeleteLogs();
   } catch (error) {
   } catch (error) {
     retrieved.value = true;
     retrieved.value = true;

+ 3 - 0
src/newtab/index.js

@@ -2,6 +2,7 @@ import { createApp } from 'vue';
 import App from './App.vue';
 import App from './App.vue';
 import router from './router';
 import router from './router';
 import store from '../store';
 import store from '../store';
+import pinia from '../lib/pinia';
 import compsUi from '../lib/compsUi';
 import compsUi from '../lib/compsUi';
 import vueI18n from '../lib/vueI18n';
 import vueI18n from '../lib/vueI18n';
 import vRemixicon, { icons } from '../lib/vRemixicon';
 import vRemixicon, { icons } from '../lib/vRemixicon';
@@ -9,11 +10,13 @@ import vueToastification from '../lib/vue-toastification';
 import '../assets/css/tailwind.css';
 import '../assets/css/tailwind.css';
 import '../assets/css/fonts.css';
 import '../assets/css/fonts.css';
 import '../assets/css/style.css';
 import '../assets/css/style.css';
+import '../assets/css/flow.css';
 
 
 createApp(App)
 createApp(App)
   .use(router)
   .use(router)
   .use(store)
   .use(store)
   .use(compsUi)
   .use(compsUi)
+  .use(pinia)
   .use(vueI18n)
   .use(vueI18n)
   .use(vueToastification)
   .use(vueToastification)
   .use(vRemixicon, icons)
   .use(vRemixicon, icons)

+ 1 - 0
src/newtab/pages/Logs.vue

@@ -136,6 +136,7 @@ const filteredLogs = computed(() => {
 
 
       return searchFilter && statusFilter && dateFilter;
       return searchFilter && statusFilter && dateFilter;
     })
     })
+    .slice()
     .sort((a, b) => {
     .sort((a, b) => {
       const valueA = a[sortsBuilder.by];
       const valueA = a[sortsBuilder.by];
       const valueB = b[sortsBuilder.by];
       const valueB = b[sortsBuilder.by];

+ 59 - 443
src/newtab/pages/Workflows.vue

@@ -10,7 +10,7 @@
             :title="shortcut['action:new'].readable"
             :title="shortcut['action:new'].readable"
             variant="accent"
             variant="accent"
             class="border-r rounded-r-none flex-1 font-semibold"
             class="border-r rounded-r-none flex-1 font-semibold"
-            @click="newWorkflow"
+            @click="addWorkflowModal.show = true"
           >
           >
             {{ t('workflow.new') }}
             {{ t('workflow.new') }}
           </ui-button>
           </ui-button>
@@ -31,7 +31,7 @@
               <ui-list-item
               <ui-list-item
                 v-close-popover
                 v-close-popover
                 class="cursor-pointer"
                 class="cursor-pointer"
-                @click="addHostWorkflow"
+                @click="addHostedWorkflow"
               >
               >
                 {{ t('workflow.host.add') }}
                 {{ t('workflow.host.add') }}
               </ui-list-item>
               </ui-list-item>
@@ -73,7 +73,7 @@
                 </span>
                 </span>
               </ui-list-item>
               </ui-list-item>
               <ui-list-item
               <ui-list-item
-                v-if="store.state.user"
+                v-if="userStore.user"
                 :active="state.activeTab === 'shared'"
                 :active="state.activeTab === 'shared'"
                 tag="button"
                 tag="button"
                 color="bg-box-transparent font-semibold"
                 color="bg-box-transparent font-semibold"
@@ -85,7 +85,7 @@
                 </span>
                 </span>
               </ui-list-item>
               </ui-list-item>
               <ui-list-item
               <ui-list-item
-                v-if="workflowHosts.length > 0"
+                v-if="hostedWorkflows.length > 0"
                 :active="state.activeTab === 'host'"
                 :active="state.activeTab === 'host'"
                 color="bg-box-transparent font-semibold"
                 color="bg-box-transparent font-semibold"
                 tag="button"
                 tag="button"
@@ -144,373 +144,117 @@
           </div>
           </div>
         </div>
         </div>
         <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
         <ui-tab-panels v-model="state.activeTab" class="flex-1 mt-6">
-          <ui-tab-panel value="shared">
-            <div class="workflows-container">
-              <shared-card
-                v-for="workflow in sharedWorkflows"
-                :key="workflow.id"
-                :data="workflow"
-                :show-details="false"
-                @execute="executeWorkflow(workflow)"
-                @click="$router.push(`/workflows/${$event.id}?shared=true`)"
-              />
-            </div>
+          <ui-tab-panel value="shared" class="workflows-container">
+            <workflows-shared
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
           </ui-tab-panel>
           </ui-tab-panel>
-          <ui-tab-panel value="host">
-            <div class="workflows-container">
-              <shared-card
-                v-for="workflow in workflowHosts"
-                :key="workflow.hostId"
-                :data="workflow"
-                :menu="workflowHostMenu"
-                @execute="executeWorkflow(workflow)"
-                @click="$router.push(`/workflows/${$event.hostId}/host`)"
-                @menuSelected="deleteWorkflowHost(workflow)"
-              />
-            </div>
+          <ui-tab-panel value="host" class="workflows-container">
+            <workflows-hosted
+              :search="state.query"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
           </ui-tab-panel>
           </ui-tab-panel>
           <ui-tab-panel value="local">
           <ui-tab-panel value="local">
-            <div
-              v-if="Workflow.all().length === 0"
-              class="py-12 flex items-center"
-            >
-              <img src="@/assets/svg/alien.svg" class="w-96" />
-              <div class="ml-4">
-                <h1 class="text-2xl font-semibold max-w-md mb-6">
-                  {{ t('message.empty') }}
-                </h1>
-                <ui-button variant="accent" @click="newWorkflow">
-                  {{ t('workflow.new') }}
-                </ui-button>
-              </div>
-            </div>
-            <template v-else>
-              <div class="workflows-container">
-                <shared-card
-                  v-for="workflow in localWorkflows"
-                  :key="workflow.id"
-                  :data="workflow"
-                  :data-workflow="workflow.id"
-                  draggable="true"
-                  class="cursor-default select-none ring-accent local-workflow"
-                  @dragstart="onDragStart"
-                  @click="$router.push(`/workflows/${$event.id}`)"
-                >
-                  <template #header>
-                    <div class="flex items-center mb-4">
-                      <template v-if="!workflow.isDisabled">
-                        <ui-img
-                          v-if="workflow.icon.startsWith('http')"
-                          :src="workflow.icon"
-                          class="rounded-lg overflow-hidden"
-                          style="height: 40px; width: 40px"
-                          alt="Can not display"
-                        />
-                        <span
-                          v-else
-                          class="p-2 rounded-lg bg-box-transparent inline-block"
-                        >
-                          <v-remixicon :name="workflow.icon" />
-                        </span>
-                      </template>
-                      <p v-else class="py-2">{{ t('common.disabled') }}</p>
-                      <div class="flex-grow"></div>
-                      <button
-                        v-if="!workflow.isDisabled"
-                        class="invisible group-hover:visible"
-                        @click="executeWorkflow(workflow)"
-                      >
-                        <v-remixicon name="riPlayLine" />
-                      </button>
-                      <v-remixicon
-                        v-if="workflow.isProtected"
-                        name="riShieldKeyholeLine"
-                        class="text-green-600 dark:text-green-400 ml-2"
-                      />
-                      <ui-popover v-if="!workflow.isProtected" class="h-6 ml-2">
-                        <template #trigger>
-                          <button>
-                            <v-remixicon name="riMoreLine" />
-                          </button>
-                        </template>
-                        <ui-list class="space-y-1" style="min-width: 150px">
-                          <ui-list-item
-                            class="cursor-pointer"
-                            @click="
-                              updateWorkflow(workflow.id, {
-                                isDisabled: !workflow.isDisabled,
-                              })
-                            "
-                          >
-                            <v-remixicon
-                              name="riToggleLine"
-                              class="mr-2 -ml-1"
-                            />
-                            <span class="capitalize">
-                              {{
-                                t(
-                                  `common.${
-                                    workflow.isDisabled ? 'enable' : 'disable'
-                                  }`
-                                )
-                              }}
-                            </span>
-                          </ui-list-item>
-                          <ui-list-item
-                            v-for="item in menu"
-                            :key="item.id"
-                            v-close-popover
-                            class="cursor-pointer"
-                            @click="menuHandlers[item.id](workflow)"
-                          >
-                            <v-remixicon :name="item.icon" class="mr-2 -ml-1" />
-                            <span class="capitalize">{{ item.name }}</span>
-                          </ui-list-item>
-                        </ui-list>
-                      </ui-popover>
-                    </div>
-                  </template>
-                  <template #footer-content>
-                    <v-remixicon
-                      v-if="sharedWorkflows[workflow.id]"
-                      v-tooltip:bottom.group="
-                        t('workflow.share.sharedAs', {
-                          name: sharedWorkflows[workflow.id]?.name.slice(0, 64),
-                        })
-                      "
-                      name="riShareLine"
-                      size="20"
-                      class="ml-2"
-                    />
-                    <v-remixicon
-                      v-if="hostWorkflows[workflow.id]"
-                      v-tooltip:bottom.group="t('workflow.host.title')"
-                      name="riBaseStationLine"
-                      size="20"
-                      class="ml-2"
-                    />
-                  </template>
-                </shared-card>
-              </div>
-              <div
-                v-if="workflows.length > 18"
-                class="flex items-center justify-between mt-8"
-              >
-                <div>
-                  {{ t('components.pagination.text1') }}
-                  <select
-                    v-model="pagination.perPage"
-                    class="p-1 rounded-md bg-input"
-                  >
-                    <option
-                      v-for="num in [18, 32, 64, 128]"
-                      :key="num"
-                      :value="num"
-                    >
-                      {{ num }}
-                    </option>
-                  </select>
-                  {{
-                    t('components.pagination.text2', {
-                      count: workflows.length,
-                    })
-                  }}
-                </div>
-                <ui-pagination
-                  v-model="pagination.currentPage"
-                  :per-page="pagination.perPage"
-                  :records="workflows.length"
-                />
-              </div>
-            </template>
+            <workflows-local
+              :search="state.query"
+              :per-page="state.perPage"
+              :folder-id="state.activeFolder"
+              :sort="{ by: state.sortBy, order: state.sortOrder }"
+            />
           </ui-tab-panel>
           </ui-tab-panel>
         </ui-tab-panels>
         </ui-tab-panels>
       </div>
       </div>
     </div>
     </div>
-    <ui-modal v-model="workflowModal.show" title="Workflow">
+    <ui-modal v-model="addWorkflowModal.show" title="Workflow">
       <ui-input
       <ui-input
-        v-model="workflowModal.name"
+        v-model="addWorkflowModal.name"
         :placeholder="t('common.name')"
         :placeholder="t('common.name')"
         autofocus
         autofocus
         class="w-full mb-4"
         class="w-full mb-4"
-        @keyup.enter="handleWorkflowModal"
+        @keyup.enter="addWorkflow"
       />
       />
       <ui-textarea
       <ui-textarea
-        v-model="workflowModal.description"
+        v-model="addWorkflowModal.description"
         :placeholder="t('common.description')"
         :placeholder="t('common.description')"
         height="165px"
         height="165px"
         class="w-full dark:text-gray-200"
         class="w-full dark:text-gray-200"
         max="300"
         max="300"
       />
       />
       <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
       <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
-        {{ workflowModal.description.length }}/300
+        {{ addWorkflowModal.description.length }}/300
       </p>
       </p>
       <div class="space-x-2 flex">
       <div class="space-x-2 flex">
-        <ui-button class="w-full" @click="workflowModal.show = false">
+        <ui-button class="w-full" @click="clearAddWorkflowModal">
           {{ t('common.cancel') }}
           {{ t('common.cancel') }}
         </ui-button>
         </ui-button>
-        <ui-button variant="accent" class="w-full" @click="handleWorkflowModal">
-          {{
-            workflowModal.type === 'update'
-              ? t('common.update')
-              : t('common.add')
-          }}
+        <ui-button variant="accent" class="w-full" @click="addWorkflow">
+          {{ t('common.add') }}
         </ui-button>
         </ui-button>
       </div>
       </div>
     </ui-modal>
     </ui-modal>
   </div>
   </div>
 </template>
 </template>
 <script setup>
 <script setup>
-import {
-  computed,
-  shallowReactive,
-  watch,
-  onMounted,
-  onBeforeUnmount,
-} from 'vue';
-import { useStore } from 'vuex';
+import { computed, shallowReactive, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
 import { useToast } from 'vue-toastification';
 import { useToast } from 'vue-toastification';
-import browser from 'webextension-polyfill';
 import { useDialog } from '@/composable/dialog';
 import { useDialog } from '@/composable/dialog';
 import { useShortcut } from '@/composable/shortcut';
 import { useShortcut } from '@/composable/shortcut';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useGroupTooltip } from '@/composable/groupTooltip';
-import { sendMessage } from '@/utils/message';
 import { fetchApi } from '@/utils/api';
 import { fetchApi } from '@/utils/api';
-import { exportWorkflow, importWorkflow } from '@/utils/workflowData';
-import {
-  registerWorkflowTrigger,
-  cleanWorkflowTriggers,
-} from '@/utils/workflowTrigger';
+import { importWorkflow } from '@/utils/workflowData';
+import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
 import { findTriggerBlock, isWhitespace } from '@/utils/helper';
 import { findTriggerBlock, isWhitespace } from '@/utils/helper';
-import SharedCard from '@/components/newtab/shared/SharedCard.vue';
-import Workflow from '@/models/workflow';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
+import WorkflowsLocal from '@/components/newtab/workflows/WorkflowsLocal.vue';
+import WorkflowsShared from '@/components/newtab/workflows/WorkflowsShared.vue';
+import WorkflowsHosted from '@/components/newtab/workflows/WorkflowsHosted.vue';
 import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
 import WorkflowsFolder from '@/components/newtab/workflows/WorkflowsFolder.vue';
-import SelectionArea from '@viselect/vanilla';
 
 
 useGroupTooltip();
 useGroupTooltip();
 const { t } = useI18n();
 const { t } = useI18n();
 const toast = useToast();
 const toast = useToast();
-const store = useStore();
 const dialog = useDialog();
 const dialog = useDialog();
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
 
 
 const sorts = ['name', 'createdAt'];
 const sorts = ['name', 'createdAt'];
-const menu = [
-  { id: 'duplicate', name: t('common.duplicate'), icon: 'riFileCopyLine' },
-  { id: 'export', name: t('common.export'), icon: 'riDownloadLine' },
-  { id: 'rename', name: t('common.rename'), icon: 'riPencilLine' },
-  { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
-];
-const workflowHostMenu = [
-  { id: 'delete', name: t('common.delete'), icon: 'riDeleteBin7Line' },
-];
 
 
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const savedSorts = JSON.parse(localStorage.getItem('workflow-sorts') || '{}');
 const state = shallowReactive({
 const state = shallowReactive({
   query: '',
   query: '',
   activeFolder: '',
   activeFolder: '',
   activeTab: 'local',
   activeTab: 'local',
-  selectedWorkflows: [],
+  perPage: savedSorts.perPage || 18,
   sortBy: savedSorts.sortBy || 'createdAt',
   sortBy: savedSorts.sortBy || 'createdAt',
   sortOrder: savedSorts.sortOrder || 'desc',
   sortOrder: savedSorts.sortOrder || 'desc',
 });
 });
-const workflowModal = shallowReactive({
+const addWorkflowModal = shallowReactive({
   name: '',
   name: '',
-  type: 'update',
+  show: false,
   description: '',
   description: '',
 });
 });
-const pagination = shallowReactive({
-  currentPage: 1,
-  perPage: savedSorts.perPage || 18,
-});
-
-const selection = new SelectionArea({
-  container: '.workflows-list',
-  startareas: ['.workflows-list'],
-  boundaries: ['.workflows-list'],
-  selectables: ['.local-workflow'],
-});
-selection
-  .on('beforestart', ({ event }) => {
-    return (
-      event.target.tagName !== 'INPUT' &&
-      !event.target.closest('.local-workflow')
-    );
-  })
-  .on('start', () => {
-    /* eslint-disable-next-line */
-  clearSelectedWorkflows();
-  })
-  .on('move', (event) => {
-    event.store.changed.added.forEach((el) => {
-      el.classList.add('ring-2');
-    });
-    event.store.changed.removed.forEach((el) => {
-      el.classList.remove('ring-2');
-    });
-  })
-  .on('stop', (event) => {
-    state.selectedWorkflows = event.store.selected.map(
-      (el) => el.dataset?.workflow
-    );
-  });
-
-const hostWorkflows = computed(() => store.state.hostWorkflows || {});
-const workflowHosts = computed(() => Object.values(store.state.workflowHosts));
-const sharedWorkflows = computed(() => store.state.sharedWorkflows || {});
-const workflows = computed(() =>
-  Workflow.query()
-    .where(
-      ({ name, folderId }) =>
-        name.toLocaleLowerCase().includes(state.query.toLocaleLowerCase()) &&
-        (!state.activeFolder || state.activeFolder === folderId)
-    )
-    .orderBy(state.sortBy, state.sortOrder)
-    .get()
-);
-const localWorkflows = computed(() =>
-  workflows.value.slice(
-    (pagination.currentPage - 1) * pagination.perPage,
-    pagination.currentPage * pagination.perPage
-  )
-);
 
 
-function clearSelectedWorkflows() {
-  state.selectedWorkflows = [];
+const hostedWorkflows = computed(() => Object.values(workflowStore.hosted));
 
 
-  selection.getSelection().forEach((el) => {
-    el.classList.remove('ring-2');
+function clearAddWorkflowModal() {
+  Object.assign(addWorkflowModal, {
+    name: '',
+    show: false,
+    description: '',
   });
   });
-  selection.clearSelection();
-}
-function onDragStart({ dataTransfer, target }) {
-  const payload = [...state.selectedWorkflows];
-
-  const targetId = target.dataset?.workflow;
-  if (targetId && !payload.includes(targetId)) payload.push(targetId);
-
-  dataTransfer.setData('workflows', JSON.stringify(payload));
 }
 }
-async function deleteWorkflowHost(workflow) {
-  dialog.confirm({
-    title: t('workflow.delete'),
-    okVariant: 'danger',
-    body: t('message.delete', { name: workflow.name }),
-    onConfirm: async () => {
-      try {
-        store.commit('deleteStateNested', `workflowHosts.${workflow.hostId}`);
-
-        await browser.storage.local.set({
-          workflowHosts: store.state.sharedWorkflows,
-        });
-        await cleanWorkflowTriggers(workflow.hostId);
-      } catch (error) {
-        console.error(error);
-      }
-    },
+function addWorkflow() {
+  workflowStore.addWorkflow({
+    name: addWorkflowModal.name,
+    description: addWorkflowModal.description,
   });
   });
+  clearAddWorkflowModal();
 }
 }
-function addHostWorkflow() {
+function addHostedWorkflow() {
   dialog.prompt({
   dialog.prompt({
     async: true,
     async: true,
     inputType: 'url',
     inputType: 'url',
@@ -527,18 +271,18 @@ function addHostWorkflow() {
         let isHostExist = false;
         let isHostExist = false;
         const hostId = value.replace(/\s/g, '');
         const hostId = value.replace(/\s/g, '');
 
 
-        workflowHosts.value.forEach((host) => {
+        hostedWorkflows.value.forEach((host) => {
           if (hostId === host.hostId) isHostExist = true;
           if (hostId === host.hostId) isHostExist = true;
 
 
           length += 1;
           length += 1;
         });
         });
 
 
-        if (!store.state.user && length >= 3) {
+        if (!userStore.user && length >= 3) {
           toast.error(t('message.rateExceeded'));
           toast.error(t('message.rateExceeded'));
           return false;
           return false;
         }
         }
 
 
-        Object.values(store.state.hostWorkflows).forEach((host) => {
+        Object.values(workflowStore.userHosted).forEach((host) => {
           if (hostId === host.hostId) isItsOwn = true;
           if (hostId === host.hostId) isItsOwn = true;
         });
         });
 
 
@@ -568,23 +312,10 @@ function addHostWorkflow() {
         result.hostId = hostId;
         result.hostId = hostId;
         result.createdAt = Date.now();
         result.createdAt = Date.now();
 
 
-        store.commit('updateStateNested', {
-          value: result,
-          path: `workflowHosts.${hostId}`,
-        });
-
+        workflowStore.hosted[hostId] = result;
         const triggerBlock = findTriggerBlock(result.drawflow);
         const triggerBlock = findTriggerBlock(result.drawflow);
         await registerWorkflowTrigger(hostId, triggerBlock);
         await registerWorkflowTrigger(hostId, triggerBlock);
 
 
-        result.drawflow = JSON.stringify(result.drawflow);
-
-        let { workflowHosts: storageHosts } = await browser.storage.local.get(
-          'workflowHosts'
-        );
-        (storageHosts = storageHosts || {})[hostId] = result;
-
-        await browser.storage.local.set({ workflowHosts: storageHosts });
-
         return true;
         return true;
       } catch (error) {
       } catch (error) {
         console.error(error);
         console.error(error);
@@ -598,126 +329,18 @@ function addHostWorkflow() {
     },
     },
   });
   });
 }
 }
-function executeWorkflow(workflow) {
-  sendMessage('workflow:execute', workflow, 'background');
-}
-function updateWorkflow(id, data) {
-  Workflow.update({
-    where: id,
-    data,
-  });
-}
-function newWorkflow() {
-  Object.assign(workflowModal, {
-    name: '',
-    show: true,
-    type: 'add',
-    description: '',
-  });
-}
-function deleteWorkflow({ name, id }) {
-  dialog.confirm({
-    title: t('workflow.delete'),
-    okVariant: 'danger',
-    body: t('message.delete', { name }),
-    onConfirm: () => {
-      Workflow.delete(id);
-    },
-  });
-}
-function deleteSelectedWorkflows({ target, key }) {
-  const excludeTags = ['INPUT', 'TEXTAREA', 'SELECT'];
-  if (
-    excludeTags.includes(target.tagName) ||
-    key !== 'Delete' ||
-    state.selectedWorkflows.length === 0
-  )
-    return;
-
-  if (state.selectedWorkflows.length === 1) {
-    const workflow = Workflow.find(state.selectedWorkflows[0]);
-    deleteWorkflow(workflow);
-  } else {
-    dialog.confirm({
-      title: t('workflow.delete'),
-      okVariant: 'danger',
-      body: t('message.delete', {
-        name: `${state.selectedWorkflows.length} workflows`,
-      }),
-      onConfirm: () => {
-        state.selectedWorkflows.forEach((id) => {
-          Workflow.delete(id);
-        });
-      },
-    });
-  }
-}
-function renameWorkflow({ id, name, description }) {
-  Object.assign(workflowModal, {
-    id,
-    name,
-    description,
-    show: true,
-    type: 'update',
-  });
-}
-async function handleWorkflowModal() {
-  try {
-    if (workflowModal.type === 'add') {
-      await Workflow.insert({
-        data: {
-          createdAt: Date.now(),
-          name: workflowModal.name || 'Unnamed',
-          description: workflowModal.description,
-        },
-      });
-    } else {
-      await Workflow.update({
-        where: workflowModal.id,
-        data: {
-          name: workflowModal.name,
-          description: workflowModal.description,
-        },
-      });
-    }
-
-    Object.assign(workflowModal, {
-      name: '',
-      show: false,
-      description: '',
-    });
-  } catch (error) {
-    console.error(error);
-  }
-}
-function duplicateWorkflow(workflow) {
-  const copyWorkflow = { ...workflow, createdAt: Date.now() };
-  const delKeys = ['$id', 'data', 'id', 'isDisabled'];
-
-  delKeys.forEach((key) => {
-    delete copyWorkflow[key];
-  });
-
-  Workflow.insert({ data: copyWorkflow });
-}
 
 
 const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
 const shortcut = useShortcut(['action:search', 'action:new'], ({ id }) => {
   if (id === 'action:search') {
   if (id === 'action:search') {
     const searchInput = document.querySelector('#search-input input');
     const searchInput = document.querySelector('#search-input input');
     searchInput?.focus();
     searchInput?.focus();
   } else {
   } else {
-    newWorkflow();
+    addWorkflowModal.show = true;
   }
   }
 });
 });
-const menuHandlers = {
-  export: exportWorkflow,
-  rename: renameWorkflow,
-  delete: deleteWorkflow,
-  duplicate: duplicateWorkflow,
-};
 
 
 watch(
 watch(
-  () => [state.sortOrder, state.sortBy, pagination.perPage],
+  () => [state.sortOrder, state.sortBy, state.perPage],
   ([sortOrder, sortBy, perPage]) => {
   ([sortOrder, sortBy, perPage]) => {
     localStorage.setItem(
     localStorage.setItem(
       'workflow-sorts',
       'workflow-sorts',
@@ -725,13 +348,6 @@ watch(
     );
     );
   }
   }
 );
 );
-
-onMounted(() => {
-  window.addEventListener('keydown', deleteSelectedWorkflows);
-});
-onBeforeUnmount(() => {
-  window.removeEventListener('keydown', deleteSelectedWorkflows);
-});
 </script>
 </script>
 <style>
 <style>
 .workflow-sort select {
 .workflow-sort select {

+ 941 - 0
src/newtab/pages/workflows/[id].old.vue

@@ -0,0 +1,941 @@
+<template>
+  <div v-if="workflow" class="flex h-screen">
+    <div
+      v-if="state.showSidebar"
+      class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
+    >
+      <workflow-edit-block
+        v-if="state.isEditBlock && workflowData.active !== 'shared'"
+        v-model:autocomplete="autocomplete.cache"
+        :data="state.blockData"
+        :data-changed="autocomplete.dataChanged"
+        :editor="editor"
+        :workflow="workflow"
+        @update="updateBlockData"
+        @close="(state.isEditBlock = false), (state.blockData = {})"
+      />
+      <workflow-details-card
+        v-else
+        :workflow="workflow"
+        :data="workflowData"
+        @update="updateWorkflow"
+      />
+    </div>
+    <div class="flex-1 relative overflow-auto">
+      <div class="absolute w-full flex items-center z-10 left-0 p-4 top-0">
+        <ui-tabs
+          v-model="activeTab"
+          class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800"
+        >
+          <button
+            v-tooltip="
+              `${t('workflow.toggleSidebar')} (${
+                shortcut['editor:toggle-sidebar'].readable
+              })`
+            "
+            style="margin-right: 6px"
+            @click="toggleSidebar"
+          >
+            <v-remixicon
+              :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
+            />
+          </button>
+          <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
+          <ui-tab value="logs" class="flex items-center">
+            {{ t('common.log', 2) }}
+            <span
+              v-if="workflowState.length > 0"
+              class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
+              style="min-width: 25px"
+            >
+              {{ workflowState.length }}
+            </span>
+          </ui-tab>
+        </ui-tabs>
+        <div class="flex-grow"></div>
+        <workflow-shared-actions
+          v-if="workflowData.active === 'shared'"
+          :data="workflowData"
+          :workflow="workflow"
+          @insertLocal="insertToLocal"
+          @update="updateSharedWorkflow"
+          @fetchLocal="fetchLocalWorkflow"
+          @save="saveUpdatedSharedWorkflow"
+          @unpublish="unpublishSharedWorkflow"
+        />
+        <workflow-actions
+          v-else
+          :data="workflowData"
+          :host="hostWorkflow"
+          :workflow="workflow"
+          :is-data-changed="state.isDataChanged"
+          @save="saveWorkflow"
+          @share="shareWorkflow"
+          @rename="renameWorkflow"
+          @update="updateWorkflow"
+          @delete="deleteWorkflow"
+          @host="setAsHostWorkflow"
+          @execute="executeWorkflow"
+          @export="workflowExporter"
+          @showModal="(state.modalName = $event), (state.showModal = true)"
+        />
+      </div>
+      <keep-alive>
+        <workflow-builder
+          v-if="activeTab === 'editor' && state.drawflow !== null"
+          class="h-full w-full"
+          :is-shared="workflowData.active === 'shared'"
+          :data="state.drawflow"
+          :version="workflow.version"
+          @save="saveWorkflow"
+          @update="updateWorkflow"
+          @load="editor = $event"
+          @loaded="onEditorLoaded"
+          @deleteBlock="deleteBlock"
+        >
+          <ui-tabs
+            v-if="
+              workflowData.hasLocal &&
+              workflowData.hasShared &&
+              !state.isDataChanged
+            "
+            v-model="workflowData.active"
+            class="z-10 text-sm"
+            color="bg-white dark:bg-gray-800"
+            type="fill"
+          >
+            <ui-tab value="local">
+              {{ t('workflow.type.local') }}
+            </ui-tab>
+            <ui-tab value="shared">
+              {{ t('workflow.type.shared') }}
+            </ui-tab>
+          </ui-tabs>
+        </workflow-builder>
+        <div v-else class="container pb-4 mt-24 px-4">
+          <template v-if="activeTab === 'logs'">
+            <div
+              v-if="(!logs || logs.length === 0) && workflowState.length === 0"
+              class="text-center"
+            >
+              <img
+                src="@/assets/svg/files-and-folder.svg"
+                class="mx-auto max-w-sm"
+              />
+              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
+            </div>
+            <shared-logs-table
+              :logs="logs"
+              :running="workflowState"
+              hide-select
+              class="w-full"
+            >
+              <template #item-append="{ log: itemLog }">
+                <td class="text-right">
+                  <v-remixicon
+                    name="riDeleteBin7Line"
+                    class="inline-block text-red-500 cursor-pointer dark:text-red-400"
+                    @click="deleteLog(itemLog.id)"
+                  />
+                </td>
+              </template>
+            </shared-logs-table>
+          </template>
+        </div>
+      </keep-alive>
+    </div>
+  </div>
+  <ui-modal
+    v-model="state.showModal"
+    :content-class="workflowModal?.width || 'max-w-xl'"
+    v-bind="workflowModal.attrs || {}"
+  >
+    <template v-if="workflowModal.title" #header>
+      {{ workflowModal.title }}
+      <a
+        v-if="workflowModal.docs"
+        :title="t('common.docs')"
+        :href="workflowModal.docs"
+        target="_blank"
+        class="inline-block align-middle"
+      >
+        <v-remixicon name="riInformationLine" size="20" />
+      </a>
+    </template>
+    <component
+      :is="workflowModal.component"
+      v-bind="{ workflow }"
+      v-on="workflowModal?.events || {}"
+      @update="updateWorkflow"
+      @close="state.showModal = false"
+    />
+  </ui-modal>
+  <ui-modal v-model="renameModal.show" title="Workflow">
+    <ui-input
+      v-model="renameModal.name"
+      :placeholder="t('common.name')"
+      class="w-full mb-4"
+      @keyup.enter="updateNameAndDesc"
+    />
+    <ui-textarea
+      v-model="renameModal.description"
+      :placeholder="t('common.description')"
+      height="165px"
+      class="w-full dark:text-gray-200"
+      max="300"
+    />
+    <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
+      {{ renameModal.description.length }}/300
+    </p>
+    <div class="space-x-2 flex">
+      <ui-button class="w-full" @click="renameModal.show = false">
+        {{ t('common.cancel') }}
+      </ui-button>
+      <ui-button variant="accent" class="w-full" @click="updateNameAndDesc">
+        {{ t('common.update') }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script setup>
+/* eslint-disable consistent-return, no-use-before-define */
+import {
+  computed,
+  reactive,
+  shallowRef,
+  provide,
+  onMounted,
+  onUnmounted,
+  toRaw,
+  watch,
+} from 'vue';
+import { useStore } from 'vuex';
+import { useToast } from 'vue-toastification';
+import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import defu from 'defu';
+import browser from 'webextension-polyfill';
+import emitter from '@/lib/mitt';
+import { useDialog } from '@/composable/dialog';
+import { useShortcut } from '@/composable/shortcut';
+import { sendMessage } from '@/utils/message';
+import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
+import { tasks } from '@/utils/shared';
+import { fetchApi } from '@/utils/api';
+import {
+  debounce,
+  isObject,
+  objectHasKey,
+  parseJSON,
+  throttle,
+} from '@/utils/helper';
+import { useLiveQuery } from '@/composable/liveQuery';
+import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
+import dbLogs from '@/db/logs';
+import Workflow from '@/models/workflow';
+import workflowTrigger from '@/utils/workflowTrigger';
+import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
+import WorkflowActions from '@/components/newtab/workflow/WorkflowActions.vue';
+import WorkflowBuilder from '@/components/newtab/workflow/WorkflowBuilder.vue';
+import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
+import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
+import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
+import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
+import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
+import WorkflowSharedActions from '@/components/newtab/workflow/WorkflowSharedActions.vue';
+import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+
+const { t } = useI18n();
+const store = useStore();
+const route = useRoute();
+const toast = useToast();
+const router = useRouter();
+const dialog = useDialog();
+const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
+const logs = useLiveQuery(() =>
+  dbLogs.items
+    .where('workflowId')
+    .equals(route.params.id)
+    .reverse()
+    .limit(15)
+    .sortBy('endedAt')
+);
+
+const activeTabQuery = route.query.tab || 'editor';
+
+const editor = shallowRef(null);
+const activeTab = shallowRef(activeTabQuery);
+
+const autocomplete = reactive({
+  cache: null,
+  dataChanged: false,
+});
+const workflowPayload = reactive({
+  data: {},
+  isUpdating: false,
+});
+const state = reactive({
+  blockData: {},
+  modalName: '',
+  drawflow: null,
+  showModal: false,
+  showSidebar: true,
+  isEditBlock: false,
+  isLoadingFlow: false,
+  isDataChanged: false,
+});
+const workflowData = reactive({
+  isHost: false,
+  hasLocal: true,
+  hasShared: false,
+  isChanged: false,
+  isUpdating: false,
+  loadingHost: false,
+  isUnpublishing: false,
+  changingKeys: new Set(),
+  active: route.query.shared ? 'shared' : 'local',
+});
+const renameModal = reactive({
+  show: false,
+  name: '',
+  description: '',
+});
+
+const workflowId = route.params.id;
+const workflowModals = {
+  table: {
+    icon: 'riKey2Line',
+    width: 'max-w-2xl',
+    component: WorkflowDataTable,
+    title: t('workflow.table.title'),
+    docs: 'https://docs.automa.site/api-reference/table.html',
+    events: {
+      change() {
+        autocomplete.dataChanged = true;
+      },
+    },
+  },
+  'workflow-share': {
+    icon: 'riShareLine',
+    component: WorkflowShare,
+    attrs: {
+      blur: true,
+      persist: true,
+      customContent: true,
+    },
+    events: {
+      close() {
+        state.showModal = false;
+        state.modalName = '';
+      },
+      publish() {
+        workflowData.hasShared = true;
+
+        state.showModal = false;
+        state.modalName = '';
+      },
+    },
+  },
+  'global-data': {
+    width: 'max-w-2xl',
+    icon: 'riDatabase2Line',
+    component: WorkflowGlobalData,
+    title: t('common.globalData'),
+    docs: 'https://docs.automa.site/api-reference/global-data.html',
+  },
+  settings: {
+    width: 'max-w-2xl',
+    icon: 'riSettings3Line',
+    component: WorkflowSettings,
+    title: t('common.settings'),
+    attrs: {
+      customContent: true,
+    },
+    events: {
+      close() {
+        state.showModal = false;
+        state.modalName = '';
+      },
+    },
+  },
+};
+
+const hostWorkflow = computed(() => store.state.hostWorkflows[workflowId]);
+const sharedWorkflow = computed(() => store.state.sharedWorkflows[workflowId]);
+const localWorkflow = computed(() => Workflow.find(workflowId));
+const workflow = computed(() =>
+  workflowData.active === 'local' ? localWorkflow.value : sharedWorkflow.value
+);
+const workflowModal = computed(() => workflowModals[state.modalName] || {});
+const workflowState = computed(() =>
+  store.getters.getWorkflowState(workflowId)
+);
+
+const updateBlockData = debounce((data) => {
+  let payload = data;
+
+  state.blockData.data = data;
+  state.isDataChanged = true;
+  autocomplete.dataChanged = true;
+
+  if (state.blockData.isInGroup) {
+    payload = { itemId: state.blockData.itemId, data };
+  } else {
+    editor.value.updateNodeDataFromId(state.blockData.blockId, data);
+  }
+
+  const inputEl = document.querySelector(
+    `#node-${state.blockData.blockId} input.trigger`
+  );
+
+  if (inputEl)
+    inputEl.dispatchEvent(
+      new CustomEvent('change', { detail: toRaw(payload) })
+    );
+}, 250);
+const executeWorkflow = throttle(() => {
+  if (editor.value.getNodesFromName('trigger').length === 0) {
+    /* eslint-disable-next-line */
+    toast.error(t('message.noTriggerBlock'));
+    return;
+  }
+
+  const payload = {
+    ...workflow.value,
+    isTesting: state.isDataChanged,
+    drawflow: JSON.stringify(editor.value.export()),
+  };
+
+  sendMessage('workflow:execute', payload, 'background');
+}, 300);
+
+async function updateHostedWorkflow() {
+  if (!store.state.user || workflowPayload.isUpdating) return;
+
+  const { backupIds } = await browser.storage.local.get('backupIds');
+  const isBackup = (backupIds || []).includes(workflowId);
+  const isExists = Workflow.query().where('id', workflowId).exists();
+
+  if (
+    (!isBackup && !workflowData.isHost) ||
+    !isExists ||
+    Object.keys(workflowPayload.data).length === 0
+  )
+    return;
+
+  workflowPayload.isUpdating = true;
+
+  try {
+    if (workflowPayload.data.drawflow) {
+      workflowPayload.data.drawflow = parseJSON(
+        workflowPayload.data.drawflow,
+        null
+      );
+    }
+
+    const response = await fetchApi(`/me/workflows/${workflowId}`, {
+      method: 'PUT',
+      keepalive: true,
+      body: JSON.stringify({
+        workflow: workflowPayload.data,
+      }),
+    });
+
+    if (!response.ok) throw new Error(response.statusText);
+
+    if (isBackup) {
+      const result = await response.json();
+
+      if (result.updatedAt) {
+        await browser.storage.local.set({ lastBackup: result.updatedAt });
+      }
+    }
+
+    workflowPayload.data = {};
+    workflowPayload.isUpdating = false;
+  } catch (error) {
+    console.error(error);
+    workflowPayload.isUpdating = false;
+  }
+}
+function unpublishSharedWorkflow() {
+  dialog.confirm({
+    title: t('workflow.unpublish.title'),
+    body: t('workflow.unpublish.body', { name: workflow.value.name }),
+    okVariant: 'danger',
+    okText: t('workflow.unpublish.button'),
+    async onConfirm() {
+      try {
+        workflowData.isUnpublishing = true;
+
+        const response = await fetchApi(`/me/workflows/shared/${workflowId}`, {
+          method: 'DELETE',
+        });
+
+        if (response.status !== 200) {
+          throw new Error(response.statusText);
+        }
+
+        store.commit('deleteStateNested', `sharedWorkflows.${workflowId}`);
+        sessionStorage.setItem(
+          'shared-workflows',
+          JSON.stringify(store.state.sharedWorkflows)
+        );
+
+        if (workflowData.hasLocal) {
+          workflowData.active = 'local';
+          workflowData.hasShared = false;
+        } else {
+          router.push('/');
+        }
+
+        workflowData.isUnpublishing = false;
+      } catch (error) {
+        console.error(error);
+        workflowData.isUnpublishing = false;
+        toast.error(t('message.somethingWrong'));
+      }
+    },
+  });
+}
+async function saveUpdatedSharedWorkflow() {
+  try {
+    workflowData.isUpdating = true;
+
+    const payload = {};
+    workflowData.changingKeys.forEach((key) => {
+      if (key === 'drawflow') {
+        payload.drawflow = JSON.parse(workflow.value.drawflow);
+      } else {
+        payload[key] = workflow.value[key];
+      }
+    });
+
+    const url = `/me/workflows/shared/${workflowId}`;
+    const response = await fetchApi(url, {
+      method: 'PUT',
+      body: JSON.stringify(payload),
+    });
+
+    if (response.status !== 200) {
+      toast.error(t('message.somethingWrong'));
+      throw new Error(response.statusText);
+    }
+
+    workflowData.isChanged = false;
+    workflowData.changingKeys.clear();
+    sessionStorage.setItem(
+      'shared-workflows',
+      JSON.stringify(store.state.sharedWorkflows)
+    );
+
+    workflowData.isUpdating = false;
+  } catch (error) {
+    console.error(error);
+    workflowData.isUpdating = false;
+  }
+}
+function updateSharedWorkflow(data = {}) {
+  Object.keys(data).forEach((key) => {
+    workflowData.changingKeys.add(key);
+  });
+
+  store.commit('updateStateNested', {
+    path: `sharedWorkflows.${workflowId}`,
+    value: {
+      ...workflow.value,
+      ...data,
+    },
+  });
+  workflowData.isChanged = true;
+}
+function fetchLocalWorkflow() {
+  const localData = {};
+  const keys = [
+    'drawflow',
+    'name',
+    'description',
+    'icon',
+    'globalData',
+    'dataColumns',
+    'table',
+    'settings',
+  ];
+
+  keys.forEach((key) => {
+    if (localWorkflow.value.isProtected && key === 'drawflow') return;
+
+    localData[key] = localWorkflow.value[key];
+  });
+
+  if (localData.drawflow) {
+    editor.value.import(JSON.parse(localData.drawflow), false);
+  }
+
+  updateSharedWorkflow(localData);
+}
+function insertToLocal() {
+  const copy = {
+    ...workflow.value,
+    createdAt: Date.now(),
+    version: browser.runtime.getManifest().version,
+  };
+
+  Workflow.insert({
+    data: copy,
+  }).then(() => {
+    workflowData.hasLocal = true;
+  });
+}
+async function setAsHostWorkflow(isHost) {
+  if (!store.state.user || isHost === 'auth') {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+    return;
+  }
+
+  workflowData.loadingHost = true;
+
+  try {
+    let url = '/me/workflows';
+    let payload = {};
+
+    if (isHost) {
+      const workflowPaylod = convertWorkflow(workflow.value, ['id']);
+      workflowPaylod.drawflow = parseJSON(workflow.value.drawflow, null);
+      delete workflowPaylod.extVersion;
+
+      url += `/host`;
+      payload = {
+        method: 'POST',
+        body: JSON.stringify({
+          workflow: workflowPaylod,
+        }),
+      };
+    } else {
+      url += `?id=${workflowId}&type=host`;
+      payload.method = 'DELETE';
+    }
+
+    const response = await fetchApi(url, payload);
+    const result = await response.json();
+
+    if (!response.ok) {
+      const error = new Error(response.statusText);
+      error.data = result.data;
+
+      throw error;
+    }
+
+    if (isHost) {
+      store.commit('updateStateNested', {
+        path: `hostWorkflows.${workflowId}`,
+        value: result,
+      });
+    } else {
+      store.commit('deleteStateNested', `hostWorkflows.${workflowId}`);
+    }
+
+    const userWorkflows = parseJSON('user-workflows', {
+      backup: [],
+      hosted: {},
+    });
+    userWorkflows.hosted = store.state.hostWorkflows;
+    sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
+
+    workflowData.isHost = isHost;
+    workflowData.loadingHost = false;
+  } catch (error) {
+    console.error(error);
+    workflowData.loadingHost = false;
+    toast.error(error.message);
+  }
+}
+function shareWorkflow() {
+  if (workflowData.hasShared) {
+    workflowData.active = 'shared';
+
+    return;
+  }
+
+  if (store.state.user) {
+    state.modalName = 'workflow-share';
+    state.showModal = true;
+  } else {
+    dialog.custom('auth', {
+      title: t('auth.title'),
+    });
+  }
+}
+function deleteLog(logId) {
+  dbLogs.items.where('id').equals(logId).delete();
+}
+function workflowExporter() {
+  const currentWorkflow = { ...workflow.value };
+
+  if (currentWorkflow.isProtected) {
+    currentWorkflow.drawflow = decryptFlow(
+      workflow.value,
+      getWorkflowPass(workflow.value.pass)
+    );
+    delete currentWorkflow.isProtected;
+  }
+
+  exportWorkflow(currentWorkflow);
+}
+function toggleSidebar() {
+  state.showSidebar = !state.showSidebar;
+  localStorage.setItem('workflow:sidebar', state.showSidebar);
+}
+function deleteBlock(id) {
+  if (!state.isEditBlock) return;
+
+  const isGroupBlock =
+    isObject(id) && id.isInGroup && id.itemId === state.blockData.itemId;
+  const isEditedBlock = state.blockData.blockId === id;
+
+  if (isEditedBlock || isGroupBlock) {
+    state.isEditBlock = false;
+    state.blockData = {};
+  }
+
+  state.isDataChanged = true;
+  autocomplete.dataChanged = true;
+}
+function updateWorkflow(data) {
+  if (workflowData.active === 'shared') return;
+
+  return Workflow.update({
+    where: workflowId,
+    data,
+  }).then((event) => {
+    delete data.id;
+    delete data.pass;
+    delete data.logs;
+    delete data.trigger;
+    delete data.createdAt;
+    delete data.isDisabled;
+    delete data.isProtected;
+
+    workflowPayload.data = { ...workflowPayload.data, ...data };
+
+    return event;
+  });
+}
+function updateNameAndDesc() {
+  updateWorkflow({
+    name: renameModal.name,
+    description: renameModal.description.slice(0, 300),
+  }).then(() => {
+    Object.assign(renameModal, {
+      show: false,
+      name: '',
+      description: '',
+    });
+  });
+}
+async function saveWorkflow() {
+  if (workflowData.active === 'shared') return;
+
+  try {
+    const flow = JSON.stringify(editor.value.export());
+    const [triggerBlockId] = editor.value.getNodesFromName('trigger');
+    const triggerBlock = editor.value.getNodeFromId(triggerBlockId);
+
+    updateWorkflow({ drawflow: flow, trigger: triggerBlock?.data }).then(() => {
+      if (triggerBlock) {
+        workflowTrigger.register(workflowId, triggerBlock);
+      }
+
+      state.isDataChanged = false;
+    });
+  } catch (error) {
+    console.error(error);
+  }
+}
+function editBlock(data) {
+  if (workflowData.active === 'shared') return;
+
+  state.isEditBlock = true;
+  state.showSidebar = true;
+  state.blockData = defu(data, tasks[data.id] || {});
+
+  if (data.id === 'wait-connections') {
+    const node = editor.value.getNodeFromId(data.blockId);
+    const connections = node.inputs.input_1.connections.map((input) => {
+      const inputNode = editor.value.getNodeFromId(input.node);
+      const nodeDesc = inputNode.data.description;
+
+      let name = t(`workflow.blocks.${inputNode.name}.name`);
+
+      if (nodeDesc) name += ` (${nodeDesc})`;
+
+      return {
+        name,
+        id: input.node,
+      };
+    });
+
+    state.blockData.connections = connections;
+  }
+}
+function handleEditorDataChanged() {
+  state.isDataChanged = true;
+  autocomplete.dataChanged = true;
+}
+function deleteWorkflow() {
+  dialog.confirm({
+    title: t('workflow.delete'),
+    okVariant: 'danger',
+    body: t('message.delete', { name: workflow.value.name }),
+    onConfirm: () => {
+      Workflow.delete(route.params.id).then(() => {
+        router.replace('/workflows');
+      });
+    },
+  });
+}
+function renameWorkflow() {
+  Object.assign(renameModal, {
+    show: true,
+    name: workflow.value.name,
+    description: workflow.value.description,
+  });
+}
+function onEditorLoaded(editorInstance) {
+  const { blockId } = route.query;
+  if (!blockId) return;
+
+  const node = editorInstance.getNodeFromId(blockId);
+  if (!node) return;
+
+  if (editorInstance.zoom !== 1) {
+    editorInstance.zoom = 1;
+    editorInstance.zoom_refresh();
+  }
+
+  const { width, height } = editorInstance.container.getBoundingClientRect();
+  const rectX = width / 2;
+  const rectY = height / 2;
+
+  editorInstance.translate_to(
+    -(node.pos_x - rectX),
+    -(node.pos_y - rectY),
+    editorInstance.zoom
+  );
+}
+
+provide('workflow', {
+  data: workflow,
+  updateWorkflow,
+  showDataColumnsModal: (show = true) => {
+    state.showModal = show;
+    state.modalName = 'table';
+  },
+});
+
+watch(activeTab, (value) => {
+  router.replace({ ...route, query: { tab: value } });
+});
+watch(() => workflowPayload.data, throttle(updateHostedWorkflow, 5000), {
+  deep: true,
+});
+watch(
+  () => workflowData.active,
+  (value) => {
+    if (value === 'shared') {
+      state.isEditBlock = false;
+      state.blockData = {};
+    }
+
+    let drawflow = parseJSON(workflow.value.drawflow, null);
+
+    if (!drawflow?.drawflow?.Home) {
+      drawflow = { drawflow: { Home: { data: {} } } };
+    }
+
+    editor.value.import(drawflow, false);
+  }
+);
+watch(
+  () => store.state.userDataRetrieved,
+  () => {
+    if (workflowData.hasShared) return;
+
+    workflowData.hasShared = objectHasKey(
+      store.state.sharedWorkflows,
+      workflowId
+    );
+    workflowData.isHost = objectHasKey(store.state.hostWorkflows, workflowId);
+  }
+);
+
+onBeforeRouteLeave(() => {
+  updateHostedWorkflow();
+
+  if (!state.isDataChanged) return;
+
+  const answer = window.confirm(t('message.notSaved'));
+
+  if (!answer) return false;
+});
+onMounted(() => {
+  const isWorkflowExists = Workflow.query().where('id', workflowId).exists();
+
+  workflowData.hasLocal = isWorkflowExists;
+  workflowData.hasShared = objectHasKey(
+    store.state.sharedWorkflows,
+    workflowId
+  );
+  workflowData.isHost = objectHasKey(store.state.hostWorkflows, workflowId);
+
+  const dontHaveLocal = !isWorkflowExists && workflowData.active === 'local';
+  const dontHaveShared =
+    !workflowData.hasShared && workflowData.active === 'shared';
+
+  if (dontHaveLocal || dontHaveShared) {
+    router.push('/workflows');
+    return;
+  }
+
+  state.drawflow = workflow.value.drawflow;
+  state.showSidebar =
+    JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
+
+  window.onbeforeunload = () => {
+    updateHostedWorkflow();
+
+    if (state.isDataChanged) {
+      return t('message.notSaved');
+    }
+  };
+
+  emitter.on('editor:edit-block', editBlock);
+  emitter.on('editor:delete-block', deleteBlock);
+  emitter.on('editor:data-changed', handleEditorDataChanged);
+});
+onUnmounted(() => {
+  window.onbeforeunload = null;
+  emitter.off('editor:edit-block', editBlock);
+  emitter.off('editor:delete-block', deleteBlock);
+  emitter.off('editor:data-changed', handleEditorDataChanged);
+});
+</script>
+<style>
+.ghost-task {
+  height: 40px;
+  @apply bg-gray-200;
+}
+.ghost-task:not(.workflow-task) * {
+  display: none;
+}
+
+.parent-drawflow.is-shared .drawflow-node * {
+  pointer-events: none;
+}
+.parent-drawflow.is-shared .drawflow-node .move-to-group,
+.parent-drawflow.is-shared .drawflow-node .menu {
+  display: none;
+}
+</style>

+ 247 - 705
src/newtab/pages/workflows/[id].vue

@@ -5,27 +5,28 @@
       class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
       class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
     >
     >
       <workflow-edit-block
       <workflow-edit-block
-        v-if="state.isEditBlock && workflowData.active !== 'shared'"
-        v-model:autocomplete="autocomplete.cache"
-        :data="state.blockData"
-        :data-changed="autocomplete.dataChanged"
-        :editor="editor"
+        v-if="editState.editing"
+        v-model:autocomplete="autocompleteState.cache"
+        :data="editState.blockData"
+        :data-changed="autocompleteState.dataChanged"
         :workflow="workflow"
         :workflow="workflow"
+        :editor="editor"
         @update="updateBlockData"
         @update="updateBlockData"
-        @close="(state.isEditBlock = false), (state.blockData = {})"
+        @close="(editState.editing = false), (editState.blockData = {})"
       />
       />
       <workflow-details-card
       <workflow-details-card
         v-else
         v-else
         :workflow="workflow"
         :workflow="workflow"
-        :data="workflowData"
         @update="updateWorkflow"
         @update="updateWorkflow"
       />
       />
     </div>
     </div>
     <div class="flex-1 relative overflow-auto">
     <div class="flex-1 relative overflow-auto">
-      <div class="absolute w-full flex items-center z-10 left-0 p-4 top-0">
+      <div
+        class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
+      >
         <ui-tabs
         <ui-tabs
-          v-model="activeTab"
-          class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800"
+          v-model="state.activeTab"
+          class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
         >
         >
           <button
           <button
             v-tooltip="
             v-tooltip="
@@ -44,118 +45,62 @@
           <ui-tab value="logs" class="flex items-center">
           <ui-tab value="logs" class="flex items-center">
             {{ t('common.log', 2) }}
             {{ t('common.log', 2) }}
             <span
             <span
-              v-if="workflowState.length > 0"
+              v-if="workflowStore.states.length > 0"
               class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
               class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
               style="min-width: 25px"
               style="min-width: 25px"
             >
             >
-              {{ workflowState.length }}
+              {{ workflowStore.states.length }}
             </span>
             </span>
           </ui-tab>
           </ui-tab>
         </ui-tabs>
         </ui-tabs>
-        <div class="flex-grow"></div>
-        <workflow-shared-actions
-          v-if="workflowData.active === 'shared'"
-          :data="workflowData"
+        <div class="flex-grow pointer-events-none" />
+        <editor-local-actions
+          :editor="editor"
           :workflow="workflow"
           :workflow="workflow"
-          @insertLocal="insertToLocal"
-          @update="updateSharedWorkflow"
-          @fetchLocal="fetchLocalWorkflow"
-          @save="saveUpdatedSharedWorkflow"
-          @unpublish="unpublishSharedWorkflow"
-        />
-        <workflow-actions
-          v-else
-          :data="workflowData"
-          :host="hostWorkflow"
-          :workflow="workflow"
-          :is-data-changed="state.isDataChanged"
-          @save="saveWorkflow"
-          @share="shareWorkflow"
-          @rename="renameWorkflow"
-          @update="updateWorkflow"
-          @delete="deleteWorkflow"
-          @host="setAsHostWorkflow"
-          @execute="executeWorkflow"
-          @export="workflowExporter"
-          @showModal="(state.modalName = $event), (state.showModal = true)"
+          :is-data-changed="state.dataChanged"
+          @save="state.dataChanged = false"
+          @modal="(modalState.name = $event), (modalState.show = true)"
         />
         />
       </div>
       </div>
-      <keep-alive>
-        <workflow-builder
-          v-if="activeTab === 'editor' && state.drawflow !== null"
-          class="h-full w-full"
-          :is-shared="workflowData.active === 'shared'"
-          :data="state.drawflow"
-          :version="workflow.version"
-          @save="saveWorkflow"
-          @update="updateWorkflow"
-          @load="editor = $event"
-          @loaded="onEditorLoaded"
-          @deleteBlock="deleteBlock"
-        >
-          <ui-tabs
-            v-if="
-              workflowData.hasLocal &&
-              workflowData.hasShared &&
-              !state.isDataChanged
-            "
-            v-model="workflowData.active"
-            class="z-10 text-sm"
-            color="bg-white dark:bg-gray-800"
-            type="fill"
-          >
-            <ui-tab value="local">
-              {{ t('workflow.type.local') }}
-            </ui-tab>
-            <ui-tab value="shared">
-              {{ t('workflow.type.shared') }}
-            </ui-tab>
-          </ui-tabs>
-        </workflow-builder>
-        <div v-else class="container pb-4 mt-24 px-4">
-          <template v-if="activeTab === 'logs'">
-            <div
-              v-if="(!logs || logs.length === 0) && workflowState.length === 0"
-              class="text-center"
-            >
-              <img
-                src="@/assets/svg/files-and-folder.svg"
-                class="mx-auto max-w-sm"
-              />
-              <p class="text-xl font-semibold">{{ t('message.noData') }}</p>
-            </div>
-            <shared-logs-table
-              :logs="logs"
-              :running="workflowState"
-              hide-select
-              class="w-full"
-            >
-              <template #item-append="{ log: itemLog }">
-                <td class="text-right">
-                  <v-remixicon
-                    name="riDeleteBin7Line"
-                    class="inline-block text-red-500 cursor-pointer dark:text-red-400"
-                    @click="deleteLog(itemLog.id)"
-                  />
-                </td>
-              </template>
-            </shared-logs-table>
-          </template>
-        </div>
-      </keep-alive>
+      <ui-tab-panels
+        v-model="state.activeTab"
+        class="overflow-hidden h-full w-full"
+        @drop="onDropInEditor"
+        @dragend="clearHighlightedElements"
+        @dragover.prevent="onDragoverEditor"
+      >
+        <ui-tab-panel cache value="editor" class="w-full">
+          <workflow-editor
+            v-if="state.workflowConverted"
+            :id="route.params.id"
+            :data="workflow.drawflow"
+            class="h-screen"
+            @init="onEditorInit"
+            @edit="initEditBlock"
+            @update:node="state.dataChanged = true"
+            @delete:node="state.dataChanged = true"
+          />
+        </ui-tab-panel>
+        <ui-tab-panel value="logs" class="mt-24">
+          <editor-logs
+            :workflow-id="route.params.id"
+            :workflow-states="workflowStore.states"
+          />
+        </ui-tab-panel>
+      </ui-tab-panels>
     </div>
     </div>
   </div>
   </div>
   <ui-modal
   <ui-modal
-    v-model="state.showModal"
-    :content-class="workflowModal?.width || 'max-w-xl'"
-    v-bind="workflowModal.attrs || {}"
+    v-model="modalState.show"
+    :content-class="activeWorkflowModal?.width || 'max-w-xl'"
+    v-bind="activeWorkflowModal.attrs || {}"
   >
   >
-    <template v-if="workflowModal.title" #header>
-      {{ workflowModal.title }}
+    <template v-if="activeWorkflowModal.title" #header>
+      {{ activeWorkflowModal.title }}
       <a
       <a
-        v-if="workflowModal.docs"
+        v-if="activeWorkflowModal.docs"
         :title="t('common.docs')"
         :title="t('common.docs')"
-        :href="workflowModal.docs"
+        :href="activeWorkflowModal.docs"
         target="_blank"
         target="_blank"
         class="inline-block align-middle"
         class="inline-block align-middle"
       >
       >
@@ -163,145 +108,75 @@
       </a>
       </a>
     </template>
     </template>
     <component
     <component
-      :is="workflowModal.component"
+      :is="activeWorkflowModal.component"
       v-bind="{ workflow }"
       v-bind="{ workflow }"
-      v-on="workflowModal?.events || {}"
+      v-on="activeWorkflowModal?.events || {}"
       @update="updateWorkflow"
       @update="updateWorkflow"
-      @close="state.showModal = false"
+      @close="modalState.show = false"
     />
     />
   </ui-modal>
   </ui-modal>
-  <ui-modal v-model="renameModal.show" title="Workflow">
-    <ui-input
-      v-model="renameModal.name"
-      :placeholder="t('common.name')"
-      class="w-full mb-4"
-      @keyup.enter="updateNameAndDesc"
-    />
-    <ui-textarea
-      v-model="renameModal.description"
-      :placeholder="t('common.description')"
-      height="165px"
-      class="w-full dark:text-gray-200"
-      max="300"
-    />
-    <p class="mb-6 text-right text-gray-600 dark:text-gray-200">
-      {{ renameModal.description.length }}/300
-    </p>
-    <div class="space-x-2 flex">
-      <ui-button class="w-full" @click="renameModal.show = false">
-        {{ t('common.cancel') }}
-      </ui-button>
-      <ui-button variant="accent" class="w-full" @click="updateNameAndDesc">
-        {{ t('common.update') }}
-      </ui-button>
-    </div>
-  </ui-modal>
 </template>
 </template>
 <script setup>
 <script setup>
-/* eslint-disable consistent-return, no-use-before-define */
 import {
 import {
-  computed,
   reactive,
   reactive,
-  shallowRef,
-  provide,
+  computed,
   onMounted,
   onMounted,
-  onUnmounted,
-  toRaw,
-  watch,
+  shallowRef,
+  onBeforeUnmount,
 } from 'vue';
 } from 'vue';
-import { useStore } from 'vuex';
-import { useToast } from 'vue-toastification';
-import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
 import { useI18n } from 'vue-i18n';
 import { useI18n } from 'vue-i18n';
-import defu from 'defu';
-import browser from 'webextension-polyfill';
-import emitter from '@/lib/mitt';
-import { useDialog } from '@/composable/dialog';
-import { useShortcut } from '@/composable/shortcut';
-import { sendMessage } from '@/utils/message';
-import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
+import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
+import { customAlphabet } from 'nanoid';
+import { useUserStore } from '@/stores/user';
+import { useWorkflowStore } from '@/stores/workflow';
 import { tasks } from '@/utils/shared';
 import { tasks } from '@/utils/shared';
+import { debounce, parseJSON, throttle } from '@/utils/helper';
 import { fetchApi } from '@/utils/api';
 import { fetchApi } from '@/utils/api';
-import {
-  debounce,
-  isObject,
-  objectHasKey,
-  parseJSON,
-  throttle,
-} from '@/utils/helper';
-import { useLiveQuery } from '@/composable/liveQuery';
-import decryptFlow, { getWorkflowPass } from '@/utils/decryptFlow';
-import dbLogs from '@/db/logs';
-import Workflow from '@/models/workflow';
-import workflowTrigger from '@/utils/workflowTrigger';
+import EditorUtils from '@/utils/EditorUtils';
+import convertWorkflowData from '@/utils/convertWorkflowData';
 import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
 import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
-import WorkflowActions from '@/components/newtab/workflow/WorkflowActions.vue';
-import WorkflowBuilder from '@/components/newtab/workflow/WorkflowBuilder.vue';
+import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
 import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
 import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
 import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
 import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
 import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
 import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
 import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
 import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
 import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
-import WorkflowSharedActions from '@/components/newtab/workflow/WorkflowSharedActions.vue';
-import SharedLogsTable from '@/components/newtab/shared/SharedLogsTable.vue';
+import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
+import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
+
+const nanoid = customAlphabet('1234567890abcdef', 7);
 
 
 const { t } = useI18n();
 const { t } = useI18n();
-const store = useStore();
 const route = useRoute();
 const route = useRoute();
-const toast = useToast();
 const router = useRouter();
 const router = useRouter();
-const dialog = useDialog();
-const shortcut = useShortcut('editor:toggle-sidebar', toggleSidebar);
-const logs = useLiveQuery(() =>
-  dbLogs.items
-    .where('workflowId')
-    .equals(route.params.id)
-    .reverse()
-    .limit(15)
-    .sortBy('endedAt')
-);
-
-const activeTabQuery = route.query.tab || 'editor';
+const userStore = useUserStore();
+const workflowStore = useWorkflowStore();
 
 
 const editor = shallowRef(null);
 const editor = shallowRef(null);
-const activeTab = shallowRef(activeTabQuery);
 
 
-const autocomplete = reactive({
-  cache: null,
+const state = reactive({
+  showSidebar: true,
   dataChanged: false,
   dataChanged: false,
+  workflowConverted: false,
+  activeTab: route.query.tab || 'editor',
 });
 });
-const workflowPayload = reactive({
-  data: {},
-  isUpdating: false,
+const modalState = reactive({
+  name: '',
+  show: false,
 });
 });
-const state = reactive({
+const editState = reactive({
   blockData: {},
   blockData: {},
-  modalName: '',
-  drawflow: null,
-  showModal: false,
-  showSidebar: true,
-  isEditBlock: false,
-  isLoadingFlow: false,
-  isDataChanged: false,
-});
-const workflowData = reactive({
-  isHost: false,
-  hasLocal: true,
-  hasShared: false,
-  isChanged: false,
-  isUpdating: false,
-  loadingHost: false,
-  isUnpublishing: false,
-  changingKeys: new Set(),
-  active: route.query.shared ? 'shared' : 'local',
+  editing: false,
 });
 });
-const renameModal = reactive({
-  show: false,
-  name: '',
-  description: '',
+const autocompleteState = reactive({
+  cache: new Map(),
+  dataChanged: false,
 });
 });
 
 
-const workflowId = route.params.id;
+const workflowPayload = {
+  data: {},
+  isUpdating: false,
+};
 const workflowModals = {
 const workflowModals = {
   table: {
   table: {
     icon: 'riKey2Line',
     icon: 'riKey2Line',
@@ -309,11 +184,6 @@ const workflowModals = {
     component: WorkflowDataTable,
     component: WorkflowDataTable,
     title: t('workflow.table.title'),
     title: t('workflow.table.title'),
     docs: 'https://docs.automa.site/api-reference/table.html',
     docs: 'https://docs.automa.site/api-reference/table.html',
-    events: {
-      change() {
-        autocomplete.dataChanged = true;
-      },
-    },
   },
   },
   'workflow-share': {
   'workflow-share': {
     icon: 'riShareLine',
     icon: 'riShareLine',
@@ -325,14 +195,12 @@ const workflowModals = {
     },
     },
     events: {
     events: {
       close() {
       close() {
-        state.showModal = false;
-        state.modalName = '';
+        modalState.show = false;
+        modalState.name = '';
       },
       },
       publish() {
       publish() {
-        workflowData.hasShared = true;
-
-        state.showModal = false;
-        state.modalName = '';
+        modalState.show = false;
+        modalState.name = '';
       },
       },
     },
     },
   },
   },
@@ -353,71 +221,54 @@ const workflowModals = {
     },
     },
     events: {
     events: {
       close() {
       close() {
-        state.showModal = false;
-        state.modalName = '';
+        modalState.show = false;
+        modalState.name = '';
       },
       },
     },
     },
   },
   },
 };
 };
 
 
-const hostWorkflow = computed(() => store.state.hostWorkflows[workflowId]);
-const sharedWorkflow = computed(() => store.state.sharedWorkflows[workflowId]);
-const localWorkflow = computed(() => Workflow.find(workflowId));
 const workflow = computed(() =>
 const workflow = computed(() =>
-  workflowData.active === 'local' ? localWorkflow.value : sharedWorkflow.value
+  workflowStore.getById('local', route.params.id)
 );
 );
-const workflowModal = computed(() => workflowModals[state.modalName] || {});
-const workflowState = computed(() =>
-  store.getters.getWorkflowState(workflowId)
+const activeWorkflowModal = computed(
+  () => workflowModals[modalState.name] || {}
 );
 );
 
 
 const updateBlockData = debounce((data) => {
 const updateBlockData = debounce((data) => {
-  let payload = data;
-
-  state.blockData.data = data;
-  state.isDataChanged = true;
-  autocomplete.dataChanged = true;
-
-  if (state.blockData.isInGroup) {
-    payload = { itemId: state.blockData.itemId, data };
-  } else {
-    editor.value.updateNodeDataFromId(state.blockData.blockId, data);
-  }
-
-  const inputEl = document.querySelector(
-    `#node-${state.blockData.blockId} input.trigger`
-  );
-
-  if (inputEl)
-    inputEl.dispatchEvent(
-      new CustomEvent('change', { detail: toRaw(payload) })
-    );
+  const node = editor.value.getNode.value(editState.blockData.blockId);
+  node.data = data;
+  state.dataChanged = true;
+  // let payload = data;
+
+  // state.blockData.data = data;
+  // state.dataChange = true;
+  // autocomplete.dataChanged = true;
+
+  // if (state.blockData.isInGroup) {
+  //   payload = { itemId: state.blockData.itemId, data };
+  // } else {
+  //   editor.value.updateNodeDataFromId(state.blockData.blockId, data);
+  // }
+
+  // const inputEl = document.querySelector(
+  //   `#node-${state.blockData.blockId} input.trigger`
+  // );
+
+  // if (inputEl)
+  //   inputEl.dispatchEvent(
+  //     new CustomEvent('change', { detail: toRaw(payload) })
+  //   );
 }, 250);
 }, 250);
-const executeWorkflow = throttle(() => {
-  if (editor.value.getNodesFromName('trigger').length === 0) {
-    /* eslint-disable-next-line */
-    toast.error(t('message.noTriggerBlock'));
-    return;
-  }
-
-  const payload = {
-    ...workflow.value,
-    isTesting: state.isDataChanged,
-    drawflow: JSON.stringify(editor.value.export()),
-  };
+const updateHostedWorkflow = throttle(async () => {
+  if (!userStore.user || workflowPayload.isUpdating) return;
 
 
-  sendMessage('workflow:execute', payload, 'background');
-}, 300);
-
-async function updateHostedWorkflow() {
-  if (!store.state.user || workflowPayload.isUpdating) return;
-
-  const { backupIds } = await browser.storage.local.get('backupIds');
-  const isBackup = (backupIds || []).includes(workflowId);
-  const isExists = Workflow.query().where('id', workflowId).exists();
+  const isHosted = workflowStore.userHosted[route.param.id];
+  const isBackup = (userStore.backupIds || []).includes(route.params.id);
+  const isExists = Boolean(workflow.value);
 
 
   if (
   if (
-    (!isBackup && !workflowData.isHost) ||
+    (!isBackup && !isHosted) ||
     !isExists ||
     !isExists ||
     Object.keys(workflowPayload.data).length === 0
     Object.keys(workflowPayload.data).length === 0
   )
   )
@@ -425,15 +276,28 @@ async function updateHostedWorkflow() {
 
 
   workflowPayload.isUpdating = true;
   workflowPayload.isUpdating = true;
 
 
+  const delKeys = [
+    'id',
+    'pass',
+    'logs',
+    'trigger',
+    'createdAt',
+    'isDisabled',
+    'isProtected',
+  ];
+  delKeys.forEach((key) => {
+    delete workflowPayload.data[key];
+  });
+
   try {
   try {
-    if (workflowPayload.data.drawflow) {
+    if (typeof workflowPayload.data.drawflow === 'string') {
       workflowPayload.data.drawflow = parseJSON(
       workflowPayload.data.drawflow = parseJSON(
         workflowPayload.data.drawflow,
         workflowPayload.data.drawflow,
-        null
+        workflowPayload.data.drawflow
       );
       );
     }
     }
 
 
-    const response = await fetchApi(`/me/workflows/${workflowId}`, {
+    const response = await fetchApi(`/me/workflows/${route.params.id}`, {
       method: 'PUT',
       method: 'PUT',
       keepalive: true,
       keepalive: true,
       body: JSON.stringify({
       body: JSON.stringify({
@@ -441,8 +305,7 @@ async function updateHostedWorkflow() {
       }),
       }),
     });
     });
 
 
-    if (!response.ok) throw new Error(response.statusText);
-
+    if (!response.ok) throw new Error(response.message);
     if (isBackup) {
     if (isBackup) {
       const result = await response.json();
       const result = await response.json();
 
 
@@ -457,485 +320,164 @@ async function updateHostedWorkflow() {
     console.error(error);
     console.error(error);
     workflowPayload.isUpdating = false;
     workflowPayload.isUpdating = false;
   }
   }
-}
-function unpublishSharedWorkflow() {
-  dialog.confirm({
-    title: t('workflow.unpublish.title'),
-    body: t('workflow.unpublish.body', { name: workflow.value.name }),
-    okVariant: 'danger',
-    okText: t('workflow.unpublish.button'),
-    async onConfirm() {
-      try {
-        workflowData.isUnpublishing = true;
-
-        const response = await fetchApi(`/me/workflows/shared/${workflowId}`, {
-          method: 'DELETE',
-        });
-
-        if (response.status !== 200) {
-          throw new Error(response.statusText);
-        }
-
-        store.commit('deleteStateNested', `sharedWorkflows.${workflowId}`);
-        sessionStorage.setItem(
-          'shared-workflows',
-          JSON.stringify(store.state.sharedWorkflows)
-        );
-
-        if (workflowData.hasLocal) {
-          workflowData.active = 'local';
-          workflowData.hasShared = false;
-        } else {
-          router.push('/');
-        }
-
-        workflowData.isUnpublishing = false;
-      } catch (error) {
-        console.error(error);
-        workflowData.isUnpublishing = false;
-        toast.error(t('message.somethingWrong'));
-      }
-    },
-  });
-}
-async function saveUpdatedSharedWorkflow() {
-  try {
-    workflowData.isUpdating = true;
-
-    const payload = {};
-    workflowData.changingKeys.forEach((key) => {
-      if (key === 'drawflow') {
-        payload.drawflow = JSON.parse(workflow.value.drawflow);
-      } else {
-        payload[key] = workflow.value[key];
-      }
-    });
+}, 5000);
 
 
-    const url = `/me/workflows/shared/${workflowId}`;
-    const response = await fetchApi(url, {
-      method: 'PUT',
-      body: JSON.stringify(payload),
-    });
+function initEditBlock(data) {
+  const { editComponent } = tasks[data.id];
 
 
-    if (response.status !== 200) {
-      toast.error(t('message.somethingWrong'));
-      throw new Error(response.statusText);
-    }
-
-    workflowData.isChanged = false;
-    workflowData.changingKeys.clear();
-    sessionStorage.setItem(
-      'shared-workflows',
-      JSON.stringify(store.state.sharedWorkflows)
-    );
-
-    workflowData.isUpdating = false;
-  } catch (error) {
-    console.error(error);
-    workflowData.isUpdating = false;
-  }
+  editState.editing = true;
+  editState.blockData = { ...data, editComponent };
 }
 }
-function updateSharedWorkflow(data = {}) {
-  Object.keys(data).forEach((key) => {
-    workflowData.changingKeys.add(key);
-  });
-
-  store.commit('updateStateNested', {
-    path: `sharedWorkflows.${workflowId}`,
-    value: {
-      ...workflow.value,
-      ...data,
-    },
+function updateWorkflow(data) {
+  workflowStore.updateWorkflow({
+    data,
+    location: 'local',
+    id: route.params.id,
   });
   });
-  workflowData.isChanged = true;
+  workflowPayload.data = { ...workflowPayload.data, ...data };
 }
 }
-function fetchLocalWorkflow() {
-  const localData = {};
-  const keys = [
-    'drawflow',
-    'name',
-    'description',
-    'icon',
-    'globalData',
-    'dataColumns',
-    'table',
-    'settings',
-  ];
-
-  keys.forEach((key) => {
-    if (localWorkflow.value.isProtected && key === 'drawflow') return;
-
-    localData[key] = localWorkflow.value[key];
+function onEditorInit(instance) {
+  editor.value = instance;
+  // listen to change event
+  instance.onEdgesChange(() => {
+    state.dataChanged = true;
   });
   });
-
-  if (localData.drawflow) {
-    editor.value.import(JSON.parse(localData.drawflow), false);
-  }
-
-  updateSharedWorkflow(localData);
 }
 }
-function insertToLocal() {
-  const copy = {
-    ...workflow.value,
-    createdAt: Date.now(),
-    version: browser.runtime.getManifest().version,
-  };
-
-  Workflow.insert({
-    data: copy,
-  }).then(() => {
-    workflowData.hasLocal = true;
+function clearHighlightedElements() {
+  const elements = document.querySelectorAll(
+    '.dropable-area__node, .dropable-area__handle'
+  );
+  elements.forEach((element) => {
+    element.classList.remove('dropable-area__node');
+    element.classList.remove('dropable-area__handle');
   });
   });
 }
 }
-async function setAsHostWorkflow(isHost) {
-  if (!store.state.user || isHost === 'auth') {
-    dialog.custom('auth', {
-      title: t('auth.title'),
-    });
-    return;
-  }
-
-  workflowData.loadingHost = true;
-
-  try {
-    let url = '/me/workflows';
-    let payload = {};
-
-    if (isHost) {
-      const workflowPaylod = convertWorkflow(workflow.value, ['id']);
-      workflowPaylod.drawflow = parseJSON(workflow.value.drawflow, null);
-      delete workflowPaylod.extVersion;
-
-      url += `/host`;
-      payload = {
-        method: 'POST',
-        body: JSON.stringify({
-          workflow: workflowPaylod,
-        }),
-      };
-    } else {
-      url += `?id=${workflowId}&type=host`;
-      payload.method = 'DELETE';
-    }
-
-    const response = await fetchApi(url, payload);
-    const result = await response.json();
-
-    if (!response.ok) {
-      const error = new Error(response.statusText);
-      error.data = result.data;
-
-      throw error;
-    }
-
-    if (isHost) {
-      store.commit('updateStateNested', {
-        path: `hostWorkflows.${workflowId}`,
-        value: result,
-      });
-    } else {
-      store.commit('deleteStateNested', `hostWorkflows.${workflowId}`);
-    }
-
-    const userWorkflows = parseJSON('user-workflows', {
-      backup: [],
-      hosted: {},
-    });
-    userWorkflows.hosted = store.state.hostWorkflows;
-    sessionStorage.setItem('user-workflows', JSON.stringify(userWorkflows));
-
-    workflowData.isHost = isHost;
-    workflowData.loadingHost = false;
-  } catch (error) {
-    console.error(error);
-    workflowData.loadingHost = false;
-    toast.error(error.message);
-  }
-}
-function shareWorkflow() {
-  if (workflowData.hasShared) {
-    workflowData.active = 'shared';
+function toggleHighlightElement({ target, elClass, classes }) {
+  const targetEl = target.closest(elClass);
 
 
-    return;
-  }
-
-  if (store.state.user) {
-    state.modalName = 'workflow-share';
-    state.showModal = true;
+  if (targetEl) {
+    targetEl.classList.add(classes);
   } else {
   } else {
-    dialog.custom('auth', {
-      title: t('auth.title'),
+    const elements = document.querySelectorAll(`.${classes}`);
+    elements.forEach((element) => {
+      element.classList.remove(classes);
     });
     });
   }
   }
 }
 }
-function deleteLog(logId) {
-  dbLogs.items.where('id').equals(logId).delete();
-}
-function workflowExporter() {
-  const currentWorkflow = { ...workflow.value };
-
-  if (currentWorkflow.isProtected) {
-    currentWorkflow.drawflow = decryptFlow(
-      workflow.value,
-      getWorkflowPass(workflow.value.pass)
-    );
-    delete currentWorkflow.isProtected;
-  }
-
-  exportWorkflow(currentWorkflow);
-}
-function toggleSidebar() {
-  state.showSidebar = !state.showSidebar;
-  localStorage.setItem('workflow:sidebar', state.showSidebar);
-}
-function deleteBlock(id) {
-  if (!state.isEditBlock) return;
-
-  const isGroupBlock =
-    isObject(id) && id.isInGroup && id.itemId === state.blockData.itemId;
-  const isEditedBlock = state.blockData.blockId === id;
-
-  if (isEditedBlock || isGroupBlock) {
-    state.isEditBlock = false;
-    state.blockData = {};
-  }
-
-  state.isDataChanged = true;
-  autocomplete.dataChanged = true;
-}
-function updateWorkflow(data) {
-  if (workflowData.active === 'shared') return;
-
-  return Workflow.update({
-    where: workflowId,
-    data,
-  }).then((event) => {
-    delete data.id;
-    delete data.pass;
-    delete data.logs;
-    delete data.trigger;
-    delete data.createdAt;
-    delete data.isDisabled;
-    delete data.isProtected;
-
-    workflowPayload.data = { ...workflowPayload.data, ...data };
-
-    return event;
+function onDragoverEditor({ target }) {
+  toggleHighlightElement({
+    target,
+    elClass: '.vue-flow__handle.source',
+    classes: 'dropable-area__handle',
   });
   });
-}
-function updateNameAndDesc() {
-  updateWorkflow({
-    name: renameModal.name,
-    description: renameModal.description.slice(0, 300),
-  }).then(() => {
-    Object.assign(renameModal, {
-      show: false,
-      name: '',
-      description: '',
-    });
-  });
-}
-async function saveWorkflow() {
-  if (workflowData.active === 'shared') return;
-
-  try {
-    const flow = JSON.stringify(editor.value.export());
-    const [triggerBlockId] = editor.value.getNodesFromName('trigger');
-    const triggerBlock = editor.value.getNodeFromId(triggerBlockId);
 
 
-    updateWorkflow({ drawflow: flow, trigger: triggerBlock?.data }).then(() => {
-      if (triggerBlock) {
-        workflowTrigger.register(workflowId, triggerBlock);
-      }
-
-      state.isDataChanged = false;
+  if (!target.closest('.vue-flow__handle')) {
+    toggleHighlightElement({
+      target,
+      elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
+      classes: 'dropable-area__node',
     });
     });
-  } catch (error) {
-    console.error(error);
   }
   }
 }
 }
-function editBlock(data) {
-  if (workflowData.active === 'shared') return;
+function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
+  const block = parseJSON(dataTransfer.getData('block'), null);
+  if (!block) return;
 
 
-  state.isEditBlock = true;
-  state.showSidebar = true;
-  state.blockData = defu(data, tasks[data.id] || {});
+  clearHighlightedElements();
 
 
-  if (data.id === 'wait-connections') {
-    const node = editor.value.getNodeFromId(data.blockId);
-    const connections = node.inputs.input_1.connections.map((input) => {
-      const inputNode = editor.value.getNodeFromId(input.node);
-      const nodeDesc = inputNode.data.description;
+  const nodeEl = EditorUtils.isNode(target);
+  if (nodeEl) {
+    EditorUtils.replaceNode(editor.value, { block, target: nodeEl });
+    return;
+  }
 
 
-      let name = t(`workflow.blocks.${inputNode.name}.name`);
+  const isTriggerExists =
+    block.id === 'trigger' &&
+    editor.value.getNodes.value.some((node) => node.label === 'trigger');
+  if (isTriggerExists) return;
+
+  const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
+  const newNode = {
+    position,
+    id: nanoid(),
+    label: block.id,
+    data: block.data,
+    type: block.component,
+  };
+  editor.value.addNodes([newNode]);
 
 
-      if (nodeDesc) name += ` (${nodeDesc})`;
+  const edgeEl = EditorUtils.isEdge(target);
+  const handleEl = EditorUtils.isHandle(target);
 
 
-      return {
-        name,
-        id: input.node,
-      };
+  if (handleEl) {
+    EditorUtils.appendNode(editor.value, {
+      target: handleEl,
+      nodeId: newNode.id,
+    });
+  } else if (edgeEl) {
+    EditorUtils.insertBetweenNode(editor.value, {
+      target: edgeEl,
+      nodeId: newNode.id,
+      outputs: block.outputs,
     });
     });
-
-    state.blockData.connections = connections;
   }
   }
-}
-function handleEditorDataChanged() {
-  state.isDataChanged = true;
-  autocomplete.dataChanged = true;
-}
-function deleteWorkflow() {
-  dialog.confirm({
-    title: t('workflow.delete'),
-    okVariant: 'danger',
-    body: t('message.delete', { name: workflow.value.name }),
-    onConfirm: () => {
-      Workflow.delete(route.params.id).then(() => {
-        router.replace('/workflows');
-      });
-    },
-  });
-}
-function renameWorkflow() {
-  Object.assign(renameModal, {
-    show: true,
-    name: workflow.value.name,
-    description: workflow.value.description,
-  });
-}
-function onEditorLoaded(editorInstance) {
-  const { blockId } = route.query;
-  if (!blockId) return;
-
-  const node = editorInstance.getNodeFromId(blockId);
-  if (!node) return;
 
 
-  if (editorInstance.zoom !== 1) {
-    editorInstance.zoom = 1;
-    editorInstance.zoom_refresh();
+  if (block.fromGroup) {
+    setTimeout(() => {
+      const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
+      blockEl?.setAttribute('group-item-id', block.itemId);
+    }, 200);
   }
   }
 
 
-  const { width, height } = editorInstance.container.getBoundingClientRect();
-  const rectX = width / 2;
-  const rectY = height / 2;
-
-  editorInstance.translate_to(
-    -(node.pos_x - rectX),
-    -(node.pos_y - rectY),
-    editorInstance.zoom
-  );
+  state.dataChanged = true;
 }
 }
-
-provide('workflow', {
-  data: workflow,
-  updateWorkflow,
-  showDataColumnsModal: (show = true) => {
-    state.showModal = show;
-    state.modalName = 'table';
-  },
-});
-
-watch(activeTab, (value) => {
-  router.replace({ ...route, query: { tab: value } });
-});
-watch(() => workflowPayload.data, throttle(updateHostedWorkflow, 5000), {
-  deep: true,
-});
-watch(
-  () => workflowData.active,
-  (value) => {
-    if (value === 'shared') {
-      state.isEditBlock = false;
-      state.blockData = {};
-    }
-
-    let drawflow = parseJSON(workflow.value.drawflow, null);
-
-    if (!drawflow?.drawflow?.Home) {
-      drawflow = { drawflow: { Home: { data: {} } } };
-    }
-
-    editor.value.import(drawflow, false);
-  }
-);
-watch(
-  () => store.state.userDataRetrieved,
-  () => {
-    if (workflowData.hasShared) return;
-
-    workflowData.hasShared = objectHasKey(
-      store.state.sharedWorkflows,
-      workflowId
-    );
-    workflowData.isHost = objectHasKey(store.state.hostWorkflows, workflowId);
-  }
-);
-
+/* eslint-disable consistent-return */
 onBeforeRouteLeave(() => {
 onBeforeRouteLeave(() => {
   updateHostedWorkflow();
   updateHostedWorkflow();
 
 
-  if (!state.isDataChanged) return;
+  if (!state.dataChange) return;
 
 
-  const answer = window.confirm(t('message.notSaved'));
+  const confirm = window.confirm(t('message.notSaved'));
 
 
-  if (!answer) return false;
+  if (!confirm) return false;
 });
 });
 onMounted(() => {
 onMounted(() => {
-  const isWorkflowExists = Workflow.query().where('id', workflowId).exists();
-
-  workflowData.hasLocal = isWorkflowExists;
-  workflowData.hasShared = objectHasKey(
-    store.state.sharedWorkflows,
-    workflowId
-  );
-  workflowData.isHost = objectHasKey(store.state.hostWorkflows, workflowId);
-
-  const dontHaveLocal = !isWorkflowExists && workflowData.active === 'local';
-  const dontHaveShared =
-    !workflowData.hasShared && workflowData.active === 'shared';
-
-  if (dontHaveLocal || dontHaveShared) {
-    router.push('/workflows');
-    return;
+  if (!workflow.value) {
+    router.replace('/');
+    return null;
   }
   }
 
 
-  state.drawflow = workflow.value.drawflow;
   state.showSidebar =
   state.showSidebar =
     JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
     JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
 
 
+  const convertedData = convertWorkflowData(workflow.value);
+  updateWorkflow({ drawflow: convertedData.drawflow });
+  state.workflowConverted = true;
+
   window.onbeforeunload = () => {
   window.onbeforeunload = () => {
     updateHostedWorkflow();
     updateHostedWorkflow();
 
 
-    if (state.isDataChanged) {
+    if (state.dataChange) {
       return t('message.notSaved');
       return t('message.notSaved');
     }
     }
+    return true;
   };
   };
-
-  emitter.on('editor:edit-block', editBlock);
-  emitter.on('editor:delete-block', deleteBlock);
-  emitter.on('editor:data-changed', handleEditorDataChanged);
 });
 });
-onUnmounted(() => {
+onBeforeUnmount(() => {
   window.onbeforeunload = null;
   window.onbeforeunload = null;
-  emitter.off('editor:edit-block', editBlock);
-  emitter.off('editor:delete-block', deleteBlock);
-  emitter.off('editor:data-changed', handleEditorDataChanged);
 });
 });
 </script>
 </script>
 <style>
 <style>
-.ghost-task {
-  height: 40px;
-  @apply bg-gray-200;
+.vue-flow,
+.editor-tab {
+  width: 100%;
+  height: 100%;
 }
 }
-.ghost-task:not(.workflow-task) * {
-  display: none;
-}
-
-.parent-drawflow.is-shared .drawflow-node * {
-  pointer-events: none;
+.vue-flow__node {
+  @apply rounded-lg;
 }
 }
-.parent-drawflow.is-shared .drawflow-node .move-to-group,
-.parent-drawflow.is-shared .drawflow-node .menu {
-  display: none;
+.dropable-area__node,
+.dropable-area__handle {
+  @apply ring-4;
 }
 }
 </style>
 </style>

+ 1 - 31
src/store/index.js

@@ -13,37 +13,7 @@ const store = createStore({
   plugins: [vuexORM(models)],
   plugins: [vuexORM(models)],
   state: () => ({
   state: () => ({
     user: null,
     user: null,
-    workflowState: [
-      {
-        id: '7F9HCTQXKMSDGlm_q_dVW',
-        state: {
-          activeTabUrl: '',
-          childWorkflowId: null,
-          tabIds: [null],
-          currentBlock: [
-            {
-              id: '1fb2464c-b94d-48f0-b40a-2903f2592428',
-              name: 'delay',
-              startedAt: 1655001148198,
-            },
-          ],
-          name: 'Child',
-          logs: [
-            {
-              type: 'success',
-              name: 'trigger',
-              blockId: '1991a5a0-a499-4c70-9040-03b37123b5df',
-              workerId: 'worker-1',
-              description: '',
-              duration: 1,
-              id: 1,
-            },
-          ],
-          startedTimestamp: 1655001148195,
-        },
-        workflowId: 'lPKjzF5cUfzckN3KgCdmX',
-      },
-    ],
+    workflowState: [],
     backupIds: [],
     backupIds: [],
     contributors: null,
     contributors: null,
     hostWorkflows: {},
     hostWorkflows: {},

+ 17 - 0
src/stores/folder.js

@@ -0,0 +1,17 @@
+import { defineStore } from 'pinia';
+import browser from 'webextension-polyfill';
+
+export const useFolderStore = defineStore('folder', {
+  state: () => ({
+    items: [],
+  }),
+  actions: {
+    load() {
+      return browser.storage.local.get('folders').then(({ folders }) => {
+        this.items = folders;
+
+        return folders;
+      });
+    },
+  },
+});

+ 27 - 0
src/stores/main.js

@@ -0,0 +1,27 @@
+import { defineStore } from 'pinia';
+import defu from 'defu';
+import browser from 'webextension-polyfill';
+
+export const useStore = defineStore('main', {
+  state: () => ({
+    user: null,
+    settings: {
+      locale: 'en',
+      deleteLogAfter: 30,
+      editor: {
+        arrow: false,
+        disableCurvature: false,
+        curvature: 0.5,
+        reroute_curvature: 0.5,
+        reroute_curvature_start_end: 0.5,
+      },
+    },
+  }),
+  actions: {
+    loadSettings() {
+      return browser.storage.local.get('settings').then(({ settings }) => {
+        this.settings = defu(settings || {}, this.settings);
+      });
+    },
+  },
+});

+ 43 - 0
src/stores/user.js

@@ -0,0 +1,43 @@
+import { defineStore } from 'pinia';
+import browser from 'webextension-polyfill';
+import { fetchApi } from '@/utils/api';
+
+export const useUserStore = defineStore('user', {
+  state: () => ({
+    user: null,
+    backupIds: [],
+    retrieved: false,
+  }),
+  actions: {
+    async loadUser() {
+      try {
+        const response = await fetchApi('/me');
+        const user = await response.json();
+
+        if (!response.ok) throw new Error(response.message);
+
+        const username = localStorage.getItem('username');
+
+        if (!user || username !== user.username) {
+          sessionStorage.removeItem('shared-workflows');
+          sessionStorage.removeItem('user-workflows');
+          sessionStorage.removeItem('backup-workflows');
+
+          await browser.storage.local.remove([
+            'backupIds',
+            'lastSync',
+            'lastBackup',
+          ]);
+
+          if (!user) return;
+        }
+
+        localStorage.setItem('username', user?.username);
+
+        this.user = user;
+      } catch (error) {
+        console.error(error);
+      }
+    },
+  },
+});

+ 184 - 0
src/stores/workflow.js

@@ -0,0 +1,184 @@
+import { defineStore } from 'pinia';
+import { nanoid } from 'nanoid';
+import defu from 'defu';
+import deepmerge from 'lodash.merge';
+import browser from 'webextension-polyfill';
+import { fetchApi } from '@/utils/api';
+import { firstWorkflows } from '@/utils/shared';
+import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
+import { parseJSON, objectHasKey, findTriggerBlock } from '@/utils/helper';
+
+function getWorkflowKey(state, id, location = 'local') {
+  let key = id;
+
+  if (Array.isArray(state[location])) {
+    const index = state[location].findIndex((workflow) => workflow.id === id);
+    key = index === -1 ? null : index;
+  } else {
+    key = objectHasKey(state[location]) ? key : null;
+  }
+
+  return key;
+}
+
+export const useWorkflowStore = defineStore('workflow', {
+  storageMap: {
+    local: 'workflows',
+    hosted: 'workflowHosts',
+    userHosted: 'hostWorkflow',
+  },
+  state: () => ({
+    local: [],
+    states: [],
+    shared: {},
+    hosted: {},
+    userHosted: {},
+    retrieved: false,
+  }),
+  getters: {
+    getById: (state) => (location, id) => {
+      let data = state[location];
+
+      if (!Array.isArray(data)) data = Object.values(data);
+
+      return data.find((item) => item.id === id);
+    },
+  },
+  actions: {
+    loadLocal() {
+      return browser.storage.local
+        .get(['workflows', 'workflowHosts', 'isFirstTime'])
+        .then(({ workflows, workflowHosts, isFirstTime }) => {
+          this.hosted = workflowHosts || {};
+          this.local = isFirstTime ? firstWorkflows : workflows;
+
+          if (isFirstTime) {
+            browser.storage.local.set({
+              isFirstTime: false,
+            });
+          }
+        });
+    },
+    loadStates() {
+      const storedStates = localStorage.getItem('workflowState') || '{}';
+      const states = parseJSON(storedStates, {});
+
+      this.states = Object.values(states).filter(
+        ({ isDestroyed }) => !isDestroyed
+      );
+    },
+    addWorkflow(data = {}) {
+      const workflow = defu(data, {
+        id: nanoid(),
+        name: '',
+        icon: 'riGlobalLine',
+        folderId: null,
+        drawflow: { edges: [], nodes: [] },
+        table: [],
+        dataColumns: [],
+        description: '',
+        trigger: null,
+        version: '',
+        createdAt: Date.now(),
+        isDisabled: false,
+        settings: {
+          publicId: '',
+          blockDelay: 0,
+          saveLog: true,
+          debugMode: false,
+          restartTimes: 3,
+          notification: true,
+          reuseLastState: false,
+          inputAutocomplete: true,
+          onError: 'stop-workflow',
+          executedBlockOnWeb: false,
+          insertDefaultColumn: true,
+          defaultColumnName: 'column',
+        },
+        globalData: '{\n\t"key": "value"\n}',
+      });
+
+      this.local.push(workflow);
+    },
+    workflowExist(id, location = 'local') {
+      let key = id;
+
+      if (Array.isArray(this[location])) {
+        const index = this.local.findIndex((workflow) => workflow.id === id);
+        key = index === -1 ? null : index;
+      } else {
+        key = objectHasKey(this[location]) ? key : null;
+      }
+
+      return Boolean(key);
+    },
+    async updateWorkflow({ id, location = 'local', data = {}, deep = false }) {
+      const key = getWorkflowKey(this, id, location);
+      if (key === null) return null;
+
+      if (deep) {
+        deepmerge(this[location][key], data);
+      } else {
+        Object.assign(this[location][key], data);
+      }
+
+      if (this.retrieved) {
+        await this.saveToStorage(location);
+      }
+
+      return this[location][key];
+    },
+    deleteWorkflow(id, location = 'local') {
+      const key = getWorkflowKey(this, id, location);
+      if (key === null) return null;
+
+      if (Array.isArray(this[location])) {
+        this[location].splice(key, 1);
+      } else {
+        delete this[location];
+      }
+
+      return id;
+    },
+    async syncHostedWorkflows() {
+      const ids = [];
+      const userHosted = Object.values(this.userHosted);
+
+      Object.keys(this.hosted).forEach((hostId) => {
+        const isItsOwn = userHosted.find((item) => item.hostId === hostId);
+
+        if (isItsOwn) return;
+
+        ids.push({ hostId, updatedAt: this.hosted[hostId].updatedAt });
+      });
+
+      const response = await fetchApi('/workflows/hosted', {
+        method: 'POST',
+        body: JSON.stringify({ hosts: ids }),
+      });
+      const result = await response.json();
+
+      if (!response.ok) throw new Error(result.message);
+
+      result.forEach(({ hostId, status, data }) => {
+        if (status === 'deleted') {
+          delete this.hosted[hostId];
+          return;
+        }
+        if (status === 'updated') {
+          const triggerBlock = findTriggerBlock(data.drawflow);
+          registerWorkflowTrigger(hostId, triggerBlock);
+        }
+
+        data.hostId = hostId;
+        this.hosted[hostId] = data;
+      });
+
+      await browser.storage.local.set({
+        workflowHosts: this.hosted,
+      });
+
+      return this.hosted;
+    },
+  },
+});

+ 165 - 0
src/utils/EditorUtils.js

@@ -0,0 +1,165 @@
+import { customAlphabet } from 'nanoid';
+import { tasks, excludeOnError } from './shared';
+
+const nanoid = customAlphabet('1234567890abcdef', 7);
+
+class EditorUtils {
+  static isNode(target) {
+    if (target.closest('.vue-flow__handle')) return null;
+
+    return target.closest('.vue-flow__node');
+  }
+
+  static isHandle(target) {
+    return target.closest('.vue-flow__handle.source');
+  }
+
+  static isEdge(target) {
+    return target.closest('.vue-flow__edge');
+  }
+
+  static replaceNode(editor, { block, target: targetEl }) {
+    const targetNode = editor.getNode.value(targetEl.dataset.id);
+
+    if (targetNode.label === 'blocks-group' || block.fromBlockBasic) return;
+
+    let blockData = block;
+    if (block.fromBlockBasic) {
+      blockData = { ...tasks[block.id], id: block.id };
+    }
+
+    const onErrorEnabled =
+      targetNode.data?.onError?.enable &&
+      !excludeOnError.includes(blockData.id);
+    const newNodeData = onErrorEnabled
+      ? { ...blockData.data, onError: targetNode.data.onError }
+      : blockData.data;
+
+    const newNode = {
+      id: nanoid(),
+      data: newNodeData,
+      label: blockData.id,
+      type: blockData.component,
+      position: targetNode.position,
+    };
+
+    const edges = editor.getEdges.value.reduce(
+      (acc, { targetHandle, sourceHandle, target, source }) => {
+        let pushData = false;
+
+        if (target === targetNode.id) {
+          targetHandle = targetHandle.replace(target, newNode.id);
+          target = newNode.id;
+          pushData = true;
+        } else if (source === targetNode.id) {
+          sourceHandle = sourceHandle.replace(source, newNode.id);
+          source = newNode.id;
+          pushData = true;
+        }
+
+        if (pushData) {
+          acc.push({
+            source,
+            target,
+            sourceHandle,
+            targetHandle,
+            id: `edge-${nanoid()}`,
+            class: `source-${sourceHandle} target-${targetHandle}`,
+          });
+        }
+
+        return acc;
+      },
+      []
+    );
+
+    editor.removeNodes([targetNode]);
+    editor.addNodes([newNode]);
+    editor.addEdges(edges);
+  }
+
+  static appendNode(editor, { target, nodeId }) {
+    const { nodeid: source, handleid } = target.dataset;
+    if (!source || !handleid) return;
+
+    editor.addEdges([
+      {
+        source,
+        target: nodeId,
+        sourceHandle: handleid,
+        targetHandle: `${nodeId}-input-1`,
+      },
+    ]);
+  }
+
+  static insertBetweenNode(editor, { target, nodeId, outputs }) {
+    if (!target) return;
+
+    const edgesChanges = [];
+    const targetEdge = {
+      target: '',
+      source: '',
+      targetHandle: '',
+      sourceHandle: '',
+    };
+
+    target.classList.forEach((name) => {
+      if (name.startsWith('source-')) {
+        const sourceHandle = name.replace('source-', '');
+        const outputIndex = sourceHandle.indexOf('-output');
+        const sourceId = sourceHandle.slice(0, outputIndex);
+
+        targetEdge.source = sourceId;
+        targetEdge.sourceHandle = sourceHandle;
+
+        return;
+      }
+
+      if (name.startsWith('target-')) {
+        const targetHandle = name.replace('target-', '');
+        const inputIndex = targetHandle.indexOf('-input');
+        const targetId = targetHandle.slice(0, inputIndex);
+
+        targetEdge.target = targetId;
+        targetEdge.targetHandle = targetHandle;
+      }
+    });
+
+    editor.getEdges.value.forEach((edge) => {
+      const matchTarget = edge.targetHandle === targetEdge.targetHandle;
+      const matchSource = edge.sourceHandle === targetEdge.sourceHandle;
+
+      if (matchTarget && matchSource) {
+        edgesChanges.push({ type: 'remove', id: edge.id });
+      }
+    });
+
+    if (outputs > 0) {
+      edgesChanges.push({
+        type: 'add',
+        item: {
+          source: nodeId,
+          id: `edge-${nanoid()}`,
+          target: targetEdge.target,
+          sourceHandle: `${nodeId}-output-1`,
+          targetHandle: targetEdge.targetHandle,
+        },
+      });
+    }
+
+    edgesChanges.push({
+      type: 'add',
+      item: {
+        target: nodeId,
+        id: `edge-${nanoid()}`,
+        source: targetEdge.source,
+        targetHandle: `${nodeId}-input-1`,
+        sourceHandle: targetEdge.sourceHandle,
+      },
+    });
+
+    editor.applyEdgeChanges(edgesChanges);
+  }
+}
+
+export default EditorUtils;

+ 83 - 0
src/utils/convertWorkflowData.js

@@ -0,0 +1,83 @@
+import { parseJSON, findTriggerBlock } from './helper';
+
+export default function (workflow) {
+  const data =
+    typeof workflow.drawflow === 'string'
+      ? parseJSON(workflow.drawflow, {})
+      : workflow.drawflow;
+  if (!data?.drawflow) return workflow;
+
+  const triggerBlock = findTriggerBlock(data);
+  if (!triggerBlock) return workflow;
+
+  const blocks = data.drawflow.Home.data;
+  const tracedBlocks = new Set();
+
+  const nodes = [];
+  const edges = [];
+
+  let edgeId = 0;
+
+  function extractBlock(blockId) {
+    if (tracedBlocks.has(blockId)) return;
+
+    const block = blocks[blockId];
+
+    nodes.push({
+      id: block.id,
+      type: block.html,
+      label: block.name,
+      position: {
+        x: block.pos_x,
+        y: block.pos_y,
+      },
+      data: block.data,
+    });
+
+    const nextBlockIds = [];
+    const outputs = Object.values(block.outputs);
+
+    outputs.forEach(({ connections }, outputIndex) => {
+      let outputName = outputIndex + 1;
+
+      const isLastIndex = outputs.length - 1 === outputIndex;
+      const isConditionsFallback = block.name === 'conditions';
+      const isFallbackBlock = block.html === 'BlockBasicWithFallback';
+      const isBlockFallback = block.html === 'BlockBasic' && outputName >= 2;
+      if (
+        (isConditionsFallback || isFallbackBlock || isBlockFallback) &&
+        isLastIndex
+      ) {
+        outputName = 'fallback';
+      }
+
+      connections.forEach(({ node: outputId, output }) => {
+        const sourceHandle = `${block.id}-output-${outputName}`;
+        const targetHandle = `${outputId}-${output.replace('_', '-')}`;
+
+        edges.push({
+          sourceHandle,
+          targetHandle,
+          source: block.id,
+          target: outputId,
+          id: `edge-${edgeId}`,
+          class: `source-${sourceHandle} target-${targetHandle}`,
+        });
+
+        nextBlockIds.push(outputId);
+        edgeId += 1;
+      });
+    });
+
+    tracedBlocks.add(blockId);
+
+    nextBlockIds.forEach((id) => {
+      extractBlock(id);
+    });
+  }
+  extractBlock(triggerBlock.id);
+
+  workflow.drawflow = { edges, nodes, x: 0, y: 0, zoom: 0 };
+
+  return workflow;
+}

+ 12 - 5
src/utils/dataMigration.js

@@ -1,13 +1,16 @@
 import browser from 'webextension-polyfill';
 import browser from 'webextension-polyfill';
 import dbLogs from '@/db/logs';
 import dbLogs from '@/db/logs';
+import convertWorkflowData from './convertWorkflowData';
 
 
 export default async function () {
 export default async function () {
   try {
   try {
-    const { logs, logsCtxData, migration } = await browser.storage.local.get([
-      'logs',
-      'migration',
-      'logsCtxData',
-    ]);
+    const { logs, logsCtxData, migration, workflows } =
+      await browser.storage.local.get([
+        'logs',
+        'migration',
+        'workflows',
+        'logsCtxData',
+      ]);
     const hasMigrated = migration || {};
     const hasMigrated = migration || {};
     const backupData = {};
     const backupData = {};
 
 
@@ -46,6 +49,10 @@ export default async function () {
       await browser.storage.local.remove('logs');
       await browser.storage.local.remove('logs');
     }
     }
 
 
+    if (!hasMigrated.workflows && workflows) {
+      workflows.forEach((workflow) => convertWorkflowData(workflow));
+    }
+
     await browser.storage.local.set({
     await browser.storage.local.set({
       migration: hasMigrated,
       migration: hasMigrated,
       ...backupData,
       ...backupData,

+ 19 - 1
src/utils/helper.js

@@ -121,7 +121,7 @@ export function parseJSON(data, def) {
 export function parseFlow(flow) {
 export function parseFlow(flow) {
   const obj = typeof flow === 'string' ? parseJSON(flow, {}) : flow;
   const obj = typeof flow === 'string' ? parseJSON(flow, {}) : flow;
 
 
-  return obj?.drawflow?.Home.data;
+  return obj;
 }
 }
 
 
 export function replaceMustache(str, replacer) {
 export function replaceMustache(str, replacer) {
@@ -237,3 +237,21 @@ export async function clearCache(workflow) {
     return false;
     return false;
   }
   }
 }
 }
+
+export function arraySorter({ data, key, order = 'asc' }) {
+  const copyData = data.slice();
+
+  return copyData.sort((a, b) => {
+    let comparison = 0;
+    const itemA = a[key] || a;
+    const itemB = b[key] || b;
+
+    if (itemA > itemB) {
+      comparison = 1;
+    } else if (itemA < itemB) {
+      comparison = -1;
+    }
+
+    return order === 'desc' ? comparison * -1 : comparison;
+  });
+}

+ 7 - 5
src/utils/shared.js

@@ -1010,23 +1010,25 @@ export const tasks = {
 export const categories = {
 export const categories = {
   interaction: {
   interaction: {
     name: 'Web interaction',
     name: 'Web interaction',
-    color: 'bg-green-200 dark:bg-green-300',
+    color: 'bg-green-200 dark:bg-green-300 fill-green-200 dark:fill-green-300',
   },
   },
   browser: {
   browser: {
     name: 'Browser',
     name: 'Browser',
-    color: 'bg-orange-200 dark:bg-orange-300',
+    color:
+      'bg-orange-200 dark:bg-orange-300 fill-orange-200 dark:fill-orange-300',
   },
   },
   general: {
   general: {
     name: 'General',
     name: 'General',
-    color: 'bg-yellow-200 dark:bg-yellow-300',
+    color:
+      'bg-yellow-200 dark:bg-yellow-300 fill-yellow-200 dark:fill-yellow-300',
   },
   },
   onlineServices: {
   onlineServices: {
     name: 'Online services',
     name: 'Online services',
-    color: 'bg-red-200 dark:bg-red-300',
+    color: 'bg-red-200 dark:bg-red-300 fill-red-200 dark:fill-red-300',
   },
   },
   conditions: {
   conditions: {
     name: 'Conditions',
     name: 'Conditions',
-    color: 'bg-blue-200 dark:bg-blue-300',
+    color: 'bg-blue-200 dark:bg-blue-300 fill-blue-200 dark:fill-blue-300',
   },
   },
 };
 };
 
 

+ 9 - 1
yarn.lock

@@ -1893,7 +1893,7 @@
     "@vue/compiler-dom" "3.2.37"
     "@vue/compiler-dom" "3.2.37"
     "@vue/shared" "3.2.37"
     "@vue/shared" "3.2.37"
 
 
-"@vue/devtools-api@^6.0.0", "@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.0.0-beta.13":
+"@vue/devtools-api@^6.0.0", "@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.0.0-beta.13", "@vue/devtools-api@^6.1.4":
   version "6.1.4"
   version "6.1.4"
   resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.1.4.tgz#b4aec2f4b4599e11ba774a50c67fa378c9824e53"
   resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.1.4.tgz#b4aec2f4b4599e11ba774a50c67fa378c9824e53"
   integrity sha512-IiA0SvDrJEgXvVxjNkHPFfDx6SXw0b/TUkqMcDZWNg9fnCAHbTpoo59YfJ9QLFkwa3raau5vSlRVzMSLDnfdtQ==
   integrity sha512-IiA0SvDrJEgXvVxjNkHPFfDx6SXw0b/TUkqMcDZWNg9fnCAHbTpoo59YfJ9QLFkwa3raau5vSlRVzMSLDnfdtQ==
@@ -5562,6 +5562,14 @@ pify@^4.0.1:
   resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
   resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
   integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
   integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
 
 
+pinia@^2.0.14:
+  version "2.0.14"
+  resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.14.tgz#0837898c20291ebac982bbfca95c8d3c6099925f"
+  integrity sha512-0nPuZR4TetT/WcLN+feMSjWJku3SQU7dBbXC6uw+R6FLQJCsg+/0pzXyD82T1FmAYe0lsx+jnEDQ1BLgkRKlxA==
+  dependencies:
+    "@vue/devtools-api" "^6.1.4"
+    vue-demi "*"
+
 pinkie-promise@^2.0.0:
 pinkie-promise@^2.0.0:
   version "2.0.1"
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
   resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"