Ahmad Kholid 2 år sedan
förälder
incheckning
1c9fdbf067
43 ändrade filer med 2216 tillägg och 264 borttagningar
  1. 0 1
      backgroundHandler/index.js
  2. 2 1
      package.json
  3. 10 0
      src/background/index.js
  4. 74 0
      src/background/workflowEngine/blocksHandler/handlerBlockPackage.js
  5. 2 2
      src/background/workflowEngine/blocksHandler/handlerBlocksGroup.js
  6. 5 4
      src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js
  7. 5 3
      src/background/workflowEngine/blocksHandler/handlerRepeatTask.js
  8. 5 2
      src/background/workflowEngine/blocksHandler/handlerWaitConnections.js
  9. 9 6
      src/background/workflowEngine/engine.js
  10. 20 10
      src/background/workflowEngine/worker.js
  11. 7 0
      src/components/block/BlockLoopBreakpoint.vue
  12. 150 0
      src/components/block/BlockNote.vue
  13. 160 0
      src/components/block/BlockPackage.vue
  14. 9 17
      src/components/block/BlockRepeatTask.vue
  15. 16 2
      src/components/newtab/app/AppSidebar.vue
  16. 58 0
      src/components/newtab/package/PackageDetails.vue
  17. 149 0
      src/components/newtab/package/PackageSettingIOSelect.vue
  18. 222 0
      src/components/newtab/package/PackageSettings.vue
  19. 6 2
      src/components/newtab/workflow/WorkflowDetailsCard.vue
  20. 10 3
      src/components/newtab/workflow/WorkflowEditor.vue
  21. 1 6
      src/components/newtab/workflow/edit/EditLoopData.vue
  22. 119 0
      src/components/newtab/workflow/editor/EditorAddPackage.vue
  23. 19 7
      src/components/newtab/workflow/editor/EditorLocalActions.vue
  24. 38 5
      src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue
  25. 61 103
      src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue
  26. 275 0
      src/components/newtab/workflow/editor/EditorPkgActions.vue
  27. 17 8
      src/components/ui/UiButton.vue
  28. 4 1
      src/content/blocksHandler/handlerTakeScreenshot.js
  29. 9 0
      src/content/commandPalette/index.js
  30. 3 0
      src/content/index.js
  31. 43 0
      src/content/services/webService.js
  32. 4 0
      src/lib/vRemixicon.js
  33. 1 0
      src/locales/en/common.json
  34. 15 0
      src/locales/en/newtab.json
  35. 8 0
      src/newtab/App.vue
  36. 243 0
      src/newtab/pages/Packages.vue
  37. 186 76
      src/newtab/pages/workflows/[id].vue
  38. 11 0
      src/newtab/router.js
  39. 107 0
      src/stores/package.js
  40. 1 0
      src/stores/user.js
  41. 3 0
      src/utils/referenceData/mustacheReplacer.js
  42. 44 1
      src/utils/shared.js
  43. 85 4
      yarn.lock

+ 0 - 1
backgroundHandler/index.js

@@ -1 +0,0 @@
-export default {};

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "automa",
-  "version": "1.17.1",
+  "version": "1.18.1",
   "description": "An extension for automating your browser by connecting blocks",
   "repository": {
     "type": "git",
@@ -57,6 +57,7 @@
     "html2canvas": "^1.4.1",
     "idb": "^7.0.2",
     "jsonpath": "^1.1.1",
+    "jspdf": "^2.5.1",
     "lodash.clonedeep": "^4.5.0",
     "lodash.merge": "^4.6.2",
     "mitt": "^3.0.0",

+ 10 - 0
src/background/index.js

@@ -591,6 +591,16 @@ message.on('get:tab-screenshot', (options) =>
   browser.tabs.captureVisibleTab(options)
 );
 
+message.on('dashboard:refresh-packages', async () => {
+  const tabs = await browser.tabs.query({ url: chrome.runtime.getURL('/*') });
+
+  tabs.forEach((tab) => {
+    browser.tabs.sendMessage(tab.id, {
+      type: 'refresh-packages',
+    });
+  });
+});
+
 message.on('workflow:execute', (workflowData, sender) => {
   if (workflowData.includeTabId) {
     if (!workflowData.options) workflowData.options = {};

+ 74 - 0
src/background/workflowEngine/blocksHandler/handlerBlockPackage.js

@@ -0,0 +1,74 @@
+export default async function (
+  { data, id },
+  { targetHandle: prevTarget, prevBlockData }
+) {
+  if (!this.engine.packagesCache[id]) {
+    this.engine.packagesCache[id] = { extracted: false, nodes: {} };
+  }
+
+  const pkgCache = this.engine.packagesCache[id];
+
+  const { 1: targetId } = prevTarget.split('input-');
+  const hasCache = pkgCache.nodes[targetId];
+  if (hasCache)
+    return {
+      data: prevBlockData,
+      nextBlockId: [{ id: hasCache }],
+    };
+
+  const input = data.inputs.find((item) => item.id === targetId);
+  if (!input) {
+    throw new Error('Input not found');
+  }
+  const block = data.data.nodes.find((node) => node.id === input.blockId);
+  pkgCache.nodes[targetId] = block.id;
+
+  const connections = {};
+
+  if (!pkgCache.extracted) {
+    const outputsMap = new Set();
+
+    data.inputs.forEach((item) => {
+      connections[item.id] = [
+        { id: item.blockId, targetId: `${block.id}-input-1` },
+      ];
+    });
+    data.outputs.forEach((output) => {
+      outputsMap.add(output.handleId);
+
+      const connection =
+        this.engine.connectionsMap[`${id}-output-${output.id}`];
+      if (!connection) return;
+
+      connections[output.handleId] = [...connection];
+    });
+
+    data.data.nodes.forEach((node) => {
+      this.engine.blocks[node.id] = { ...node };
+    });
+
+    if (!block) {
+      throw new Error(`Can't find block for this input`);
+    }
+
+    data.data.edges.forEach(({ sourceHandle, target, targetHandle }) => {
+      if (outputsMap.has(sourceHandle)) return;
+
+      if (!connections[sourceHandle]) connections[sourceHandle] = [];
+      connections[sourceHandle].push({
+        id: target,
+        targetHandle,
+        sourceHandle,
+      });
+    });
+
+    pkgCache.extracted = true;
+  }
+
+  Object.assign(this.engine.connectionsMap, connections);
+
+  return {
+    data: prevBlockData,
+    nextBlockId: [{ id: block.id }],
+  };
+}

+ 2 - 2
src/background/workflowEngine/blocksHandler/handlerBlocksGroup.js

@@ -27,7 +27,7 @@ function blocksGroup({ data, id }, { prevBlockData }) {
           if (!acc.connections[outputId]) {
             acc.connections[outputId] = [];
           }
-          acc.connections[outputId].push(nextBlock);
+          acc.connections[outputId].push({ id: nextBlock });
         }
 
         return acc;
@@ -40,7 +40,7 @@ function blocksGroup({ data, id }, { prevBlockData }) {
 
     resolve({
       data: prevBlockData,
-      nextBlockId: [data.blocks[0].itemId],
+      nextBlockId: [{ id: data.blocks[0].itemId }],
     });
   });
 }

+ 5 - 4
src/background/workflowEngine/blocksHandler/handlerLoopBreakpoint.js

@@ -11,14 +11,15 @@ function loopBreakpoint(block, { prevBlockData }) {
           : currentLoop.index <= currentLoop.data.length - 1;
     }
 
-    if (
+    const continueLoop =
       currentLoop &&
       currentLoop.index < currentLoop.maxLoop - 1 &&
-      validLoopData
-    ) {
+      validLoopData;
+
+    if (!block.data.clearLoop && continueLoop) {
       resolve({
         data: '',
-        nextBlockId: [currentLoop.blockId],
+        nextBlockId: [{ id: currentLoop.blockId }],
       });
     } else {
       if (currentLoop.type === 'elements') {

+ 5 - 3
src/background/workflowEngine/blocksHandler/handlerRepeatTask.js

@@ -1,17 +1,19 @@
 function repeatTask({ data, id }) {
   return new Promise((resolve) => {
-    if (this.repeatedTasks[id] >= data.repeatFor) {
+    const repeat = Number.isNaN(+data.repeatFor) ? 0 : +data.repeatFor;
+
+    if (this.repeatedTasks[id] > repeat) {
       delete this.repeatedTasks[id];
 
       resolve({
-        data: data.repeatFor,
+        data: repeat,
         nextBlockId: this.getBlockConnections(id),
       });
     } else {
       this.repeatedTasks[id] = (this.repeatedTasks[id] || 1) + 1;
 
       resolve({
-        data: data.repeatFor,
+        data: repeat,
         nextBlockId: this.getBlockConnections(id, 2),
       });
     }

+ 5 - 2
src/background/workflowEngine/blocksHandler/handlerWaitConnections.js

@@ -10,10 +10,13 @@ async function waitConnections({ data, id }, { prevBlock }) {
     const registerConnections = () => {
       const connections = this.engine.connectionsMap;
       Object.keys(connections).forEach((key) => {
-        const isConnected = connections[key].includes(id);
+        const isConnected = connections[key].some(
+          (connection) => connection.id === id
+        );
         if (!isConnected) return;
 
-        const prevBlockId = key.slice(0, key.indexOf('-output'));
+        const index = key.indexOf('-output');
+        const prevBlockId = key.slice(0, index === -1 ? key.length : index);
         this.engine.waitConnections[id][prevBlockId] = {
           isHere: false,
           isContinue: false,

+ 9 - 6
src/background/workflowEngine/engine.js

@@ -18,6 +18,7 @@ class WorkflowEngine {
     this.workerId = 0;
     this.workers = new Map();
 
+    this.packagesCache = {};
     this.extractedGroup = {};
     this.connectionsMap = {};
     this.waitConnections = {};
@@ -146,13 +147,15 @@ class WorkflowEngine {
 
         return acc;
       }, {});
-      this.connectionsMap = edges.reduce((acc, { sourceHandle, target }) => {
-        if (!acc[sourceHandle]) acc[sourceHandle] = [];
+      this.connectionsMap = edges.reduce(
+        (acc, { sourceHandle, target, targetHandle }) => {
+          if (!acc[sourceHandle]) acc[sourceHandle] = [];
+          acc[sourceHandle].push({ id: target, targetHandle, sourceHandle });
 
-        acc[sourceHandle].push(target);
-
-        return acc;
-      }, {});
+          return acc;
+        },
+        {}
+      );
 
       const workflowTable = this.workflow.table || this.workflow.dataColumns;
       let columns = Array.isArray(workflowTable)

+ 20 - 10
src/background/workflowEngine/worker.js

@@ -38,7 +38,7 @@ class Worker {
     };
   }
 
-  init({ blockId, prevBlockData, state }) {
+  init({ blockId, execParam, state }) {
     if (state) {
       Object.keys(state).forEach((key) => {
         this[key] = state[key];
@@ -46,7 +46,7 @@ class Worker {
     }
 
     const block = this.engine.blocks[blockId];
-    this.executeBlock(block, prevBlockData);
+    this.executeBlock(block, execParam);
   }
 
   addDataToColumn(key, value) {
@@ -94,9 +94,18 @@ class Worker {
   }
 
   executeNextBlocks(connections, prevBlockData) {
-    connections.forEach((nodeId, index) => {
+    connections.forEach((connection, index) => {
+      const { id, targetHandle, sourceHandle } =
+        typeof connection === 'string'
+          ? { id: connection, targetHandle: '', sourceHandle: '' }
+          : connection;
+      const execParam = { prevBlockData, targetHandle, sourceHandle };
+
       if (index === 0) {
-        this.executeBlock(this.engine.blocks[nodeId], prevBlockData);
+        this.executeBlock(this.engine.blocks[id], {
+          prevBlockData,
+          ...execParam,
+        });
       } else {
         const state = structuredClone({
           windowId: this.windowId,
@@ -109,14 +118,14 @@ class Worker {
 
         this.engine.addWorker({
           state,
-          prevBlockData,
-          blockId: nodeId,
+          execParam,
+          blockId: id,
         });
       }
     });
   }
 
-  async executeBlock(block, prevBlockData, isRetry) {
+  async executeBlock(block, execParam = {}, isRetry = false) {
     const currentState = await this.engine.states.get(this.engine.id);
 
     if (!currentState || currentState.isDestroyed) {
@@ -149,6 +158,7 @@ class Worker {
       return;
     }
 
+    const { prevBlockData } = execParam;
     const refData = {
       prevBlockData,
       ...this.engine.referenceData,
@@ -190,7 +200,7 @@ class Worker {
         result = await handler.call(this, replacedBlock, {
           refData,
           prevBlock,
-          prevBlockData,
+          ...(execParam || {}),
         });
 
         if (result.replacedValue) {
@@ -216,7 +226,7 @@ class Worker {
         if (blockOnError.retry && blockOnError.retryTimes) {
           await sleep(blockOnError.retryInterval * 1000);
           blockOnError.retryTimes -= 1;
-          await this.executeBlock(replacedBlock, prevBlockData, true);
+          await this.executeBlock(replacedBlock, execParam, true);
 
           return;
         }
@@ -276,7 +286,7 @@ class Worker {
         this.reset();
 
         const triggerBlock = this.engine.blocks[this.engine.triggerBlockId];
-        this.executeBlock(triggerBlock);
+        this.executeBlock(triggerBlock, execParam);
 
         localStorage.setItem(restartKey, restartCount + 1);
       } else {

+ 7 - 0
src/components/block/BlockLoopBreakpoint.vue

@@ -24,6 +24,13 @@
       required
       @input="handleInput"
     />
+    <ui-checkbox
+      :value="data.clearLoop"
+      class="mt-2"
+      @change="$emit('update', { clearLoop: $event })"
+    >
+      Stop loop
+    </ui-checkbox>
     <Handle :id="`${id}-output-1`" type="source" :position="Position.Right" />
   </ui-card>
 </template>

+ 150 - 0
src/components/block/BlockNote.vue

@@ -0,0 +1,150 @@
+<template>
+  <div
+    :class="[data.color || 'white', colors[data.color || 'white']]"
+    class="p-4 rounded-lg block-note"
+    style="min-width: 192px"
+  >
+    <div class="pb-2 border-b flex items-center">
+      <v-remixicon name="riFileEditLine" size="20" />
+      <p class="flex-1 ml-2 mr-2 font-semibold">Note</p>
+      <ui-popover class="note-color">
+        <template #trigger>
+          <v-remixicon
+            name="riSettings3Line"
+            size="20"
+            class="cursor-pointer"
+          />
+        </template>
+        <p class="mb-1 ml-1 text-sm text-gray-600 dark:text-gray-200">Colors</p>
+        <div class="flex items-center space-x-2">
+          <span
+            v-for="(color, colorId) in colors"
+            :key="colorId"
+            :class="color"
+            style="border-width: 3px"
+            class="h-8 w-8 rounded-full inline-block cursor-pointer"
+            @click="updateData({ color: colorId })"
+          />
+        </div>
+        <ui-select
+          :model-value="data.fontSize"
+          label="Font size"
+          class="mt-2 w-full"
+          @change="updateData({ fontSize: $event })"
+        >
+          <option
+            v-for="(size, fontId) in fontSize"
+            :key="fontId"
+            :value="fontId"
+          >
+            {{ size.name }}
+          </option>
+        </ui-select>
+      </ui-popover>
+      <hr class="mx-2 h-7 border-r" />
+      <v-remixicon
+        name="riDeleteBin7Line"
+        size="20"
+        class="cursor-pointer"
+        @click="$emit('delete', id)"
+      />
+    </div>
+    <textarea
+      :model-value="data.note"
+      :style="initialSize"
+      :class="[fontSize[data.fontSize || 'regular'].class]"
+      placeholder="Write a note here..."
+      cols="30"
+      rows="7"
+      style="resize: both; min-width: 280px; min-height: 168px"
+      class="focus:ring-0 mt-2 bg-transparent"
+      @input="updateData({ note: $event.target.value })"
+      @mousedown.stop
+      @mouseup="onMouseup"
+    />
+  </div>
+</template>
+<script setup>
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update', 'delete']);
+
+const initialSize = {
+  width: `${props.data.width}px`,
+  height: `${props.data.height}px`,
+};
+
+const colors = {
+  white: 'bg-white dark:bg-gray-800',
+  red: 'bg-red-200 dark:bg-red-300',
+  indigo: 'bg-indigo-200 dark:bg-indigo-300',
+  green: 'bg-green-200 dark:bg-green-300',
+  amber: 'bg-amber-200 dark:bg-amber-300',
+  sky: 'bg-sky-200 dark:bg-sky-300',
+};
+const fontSize = {
+  regular: {
+    name: 'Regular',
+    class: 'text-base',
+  },
+  medium: {
+    name: 'Medium',
+    class: 'text-xl',
+  },
+  large: {
+    name: 'Large',
+    class: 'text-2xl',
+  },
+  'extra-large': {
+    name: 'Extra Large',
+    class: 'text-3xl',
+  },
+};
+
+const updateData = debounce((data) => {
+  emit('update', data);
+}, 250);
+
+function onMouseup({ target }) {
+  let { height, width } = target.style;
+  width = parseInt(width, 10);
+  height = parseInt(height, 10);
+
+  if (width === props.data.width && height === props.data.height) return;
+
+  updateData({ height, width });
+}
+</script>
+<style>
+.note-color .ui-popover__trigger {
+  @apply flex items-center;
+}
+.block-note * {
+  border-color: rgb(0 0 0 / 12%);
+}
+.dark .block-note {
+  &:not(.white) {
+    @apply text-gray-900;
+  }
+  &.white * {
+    border-color: rgb(255 255 255 / 12%);
+  }
+  * {
+    border-color: rgb(0 0 0 / 12%);
+  }
+}
+</style>

+ 160 - 0
src/components/block/BlockPackage.vue

@@ -0,0 +1,160 @@
+<template>
+  <ui-card :id="componentId" class="p-4 w-64 block-package">
+    <div class="flex items-center">
+      <img
+        v-if="data.icon.startsWith('http')"
+        :src="data.icon"
+        width="38"
+        height="38"
+        class="mr-2 rounded-lg"
+      />
+      <div
+        :class="data.disableBlock ? 'bg-box-transparent' : block.category.color"
+        class="inline-block text-sm mr-4 p-2 rounded-lg dark:text-black overflow-hidden"
+      >
+        <v-remixicon
+          v-if="!data.icon.startsWith('http')"
+          :name="data.icon"
+          size="20"
+          class="inline-block mr-1"
+        />
+        <span class="text-overflow">{{ data.name || 'Unnamed package' }}</span>
+      </div>
+      <div class="flex-grow" />
+      <v-remixicon
+        title="Update package"
+        name="riRefreshLine"
+        class="cursor-pointer"
+        @click="updatePackage"
+      />
+    </div>
+    <div class="grid grid-cols-2 mt-4 gap-x-2">
+      <ul class="pkg-handle-container">
+        <li
+          v-for="input in data.inputs"
+          :key="input.id"
+          :title="input.name"
+          class="relative target"
+        >
+          <Handle
+            :id="`${id}-input-${input.id}`"
+            type="target"
+            :position="Position.Left"
+          />
+          <p class="text-overflow">{{ input.name }}</p>
+        </li>
+      </ul>
+      <ul class="pkg-handle-container">
+        <li
+          v-for="output in data.outputs"
+          :key="output.id"
+          :title="output.name"
+          class="relative source"
+        >
+          <Handle
+            :id="`${id}-output-${output.id}`"
+            type="source"
+            :position="Position.Right"
+          />
+          <p class="text-overflow">{{ output.name }}</p>
+        </li>
+      </ul>
+    </div>
+    <div
+      v-if="data.author"
+      class="mt-1 text-sm text-gray-600 flex items-center dark:text-gray-200"
+    >
+      <p>By {{ data.author }}</p>
+      <a
+        :href="`https://automa.site/packages/${data.id}`"
+        target="_blank"
+        title="Open package page"
+        class="ml-2"
+      >
+        <v-remixicon size="18" name="riExternalLinkLine" />
+      </a>
+    </div>
+  </ui-card>
+</template>
+<script setup>
+import cloneDeep from 'lodash.clonedeep';
+import { Handle, Position } from '@braks/vue-flow';
+import { usePackageStore } from '@/stores/package';
+import { useComponentId } from '@/composable/componentId';
+import { useEditorBlock } from '@/composable/editorBlock';
+
+const props = defineProps({
+  id: {
+    type: String,
+    default: '',
+  },
+  label: {
+    type: String,
+    default: '',
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: {
+    type: Object,
+    default: null,
+  },
+});
+const emit = defineEmits(['update', 'delete']);
+
+const packageStore = usePackageStore();
+const block = useEditorBlock(props.label);
+const componentId = useComponentId('block-package');
+
+function removeConnections(type, old, newEdges) {
+  const removedEdges = [];
+  old.forEach((edge) => {
+    const isNotDeleted = newEdges.find((item) => item.id === edge.id);
+    if (isNotDeleted) return;
+
+    const handleType = type.slice(0, -1);
+
+    removedEdges.push(`${props.id}-${handleType}-${edge.id}`);
+  });
+
+  const edgesToRemove = props.editor.getEdges.value.filter(
+    ({ sourceHandle, targetHandle }) => {
+      if (type === 'outputs') {
+        return removedEdges.includes(sourceHandle);
+      }
+
+      return removedEdges.includes(targetHandle);
+    }
+  );
+
+  props.editor.removeEdges(edgesToRemove);
+}
+function updatePackage() {
+  const pkg = packageStore.getById(props.data.id);
+  if (!pkg) return;
+
+  const currentInputs = [...props.data.inputs];
+  const currentOutputs = [...props.data.outputs];
+
+  removeConnections('inputs', currentInputs, pkg.inputs);
+  removeConnections('outputs', currentOutputs, pkg.outputs);
+
+  emit('update', cloneDeep(pkg));
+}
+</script>
+<style>
+.pkg-handle-container li {
+  @apply h-8 flex items-center text-sm;
+
+  &.target .vue-flow__handle {
+    margin-left: -33px;
+  }
+  &.source {
+    @apply justify-end;
+    .vue-flow__handle {
+      margin-right: -33px;
+    }
+  }
+}
+</style>

+ 9 - 17
src/components/block/BlockRepeatTask.vue

@@ -1,5 +1,5 @@
 <template>
-  <ui-card :id="componentId" class="p-4 repeat-task">
+  <ui-card :id="componentId" class="p-4 repeat-task w-64">
     <Handle :id="`${id}-input-1`" type="target" :position="Position.Left" />
     <div class="flex items-center mb-2">
       <div
@@ -16,21 +16,19 @@
         @click="$emit('delete', id)"
       />
     </div>
-    <label
-      class="mb-2 block bg-input focus-within:bg-input pr-4 transition rounded-lg"
-    >
+    <div class="flex bg-input rounded-lg items-center relative">
       <input
-        :value="data.repeatFor || 0"
-        min="0"
-        class="pl-4 py-2 bg-transparent rounded-l-lg w-24 mr-2"
-        type="number"
+        :value="data.repeatFor"
+        placeholder="0"
+        class="bg-transparent py-2 px-4 focus:ring-0"
         required
+        style="padding-right: 57px; width: 95%"
         @input="handleInput"
       />
-      <span class="text-gray-600 dark:text-gray-200">
+      <span class="text-gray-600 dark:text-gray-200 absolute right-4">
         {{ t('workflow.blocks.repeat-task.times') }}
       </span>
-    </label>
+    </div>
     <p class="text-right text-gray-600 dark:text-gray-200">
       {{ t('workflow.blocks.repeat-task.repeatFrom') }}
     </p>
@@ -70,13 +68,7 @@ const block = useEditorBlock(props.label);
 const componentId = useComponentId('block-delay');
 
 function handleInput({ target }) {
-  target.reportValidity();
-
-  const repeatFor = +target.value || 0;
-
-  if (repeatFor < 0) return;
-
-  emit('update', { repeatFor });
+  emit('update', { repeatFor: target.value });
 }
 </script>
 <style>

+ 16 - 2
src/components/newtab/app/AppSidebar.vue

@@ -26,7 +26,9 @@
       >
         <a
           v-tooltip:right.group="
-            `${t(`common.${tab.id}`, 2)} (${tab.shortcut.readable})`
+            `${t(`common.${tab.id}`, 2)} ${
+              tab.shortcut && `(${tab.shortcut.readable})`
+            }`
           "
           :class="{ 'is-active': isActive }"
           :href="href"
@@ -133,6 +135,12 @@ const tabs = [
     path: '/workflows',
     shortcut: getShortcut('page:workflows', '/workflows'),
   },
+  {
+    id: 'packages',
+    icon: 'mdiPackageVariantClosed',
+    path: '/packages',
+    shortcut: '',
+  },
   {
     id: 'schedule',
     icon: 'riTimeLine',
@@ -163,7 +171,13 @@ const showHoverIndicator = ref(false);
 const runningWorkflowsLen = computed(() => workflowStore.states.length);
 
 useShortcut(
-  tabs.map(({ shortcut }) => shortcut),
+  tabs.reduce((acc, { shortcut }) => {
+    if (shortcut) {
+      acc.push(shortcut);
+    }
+
+    return acc;
+  }, []),
   ({ data }) => {
     if (!data) return;
 

+ 58 - 0
src/components/newtab/package/PackageDetails.vue

@@ -0,0 +1,58 @@
+<template>
+  <div class="w-full max-w-2xl mt-8 pb-8">
+    <ui-input
+      :model-value="data.name"
+      label="Package name"
+      class="w-full"
+      placeholder="My package"
+      @change="updatePackage({ name: $event })"
+    />
+    <label class="mt-4 block w-full">
+      <span class="text-sm ml-1 text-gray-600 dark:text-gray-200">
+        Short description
+      </span>
+      <ui-textarea
+        :model-value="data.description"
+        placeholder="Short description"
+        @change="updatePackage({ description: $event })"
+      />
+    </label>
+    <shared-wysiwyg
+      :model-value="data.content"
+      :placeholder="$t('common.description')"
+      :limit="5000"
+      class="prose prose-zinc dark:prose-invert mt-4 max-w-none content-editor p-4 bg-box-transparent rounded-lg relative"
+      @change="updatePackage({ content: $event })"
+      @count="state.contentLength = $event"
+    >
+      <template #append>
+        <p
+          class="text-sm text-gray-600 dark:text-gray-200 absolute bottom-2 right-2"
+        >
+          {{ state.contentLength }}/5000
+        </p>
+      </template>
+    </shared-wysiwyg>
+  </div>
+</template>
+<script setup>
+import { reactive } from 'vue';
+import { debounce } from '@/utils/helper';
+import SharedWysiwyg from '@/components/newtab/shared/SharedWysiwyg.vue';
+
+defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const state = reactive({
+  contentLength: 0,
+});
+
+const updatePackage = debounce((data) => {
+  emit('update', data);
+}, 400);
+</script>

+ 149 - 0
src/components/newtab/package/PackageSettingIOSelect.vue

@@ -0,0 +1,149 @@
+<template>
+  <ui-popover>
+    <template #trigger>
+      <ui-button class="w-full">
+        Select block {{ props.data.blockId }}
+      </ui-button>
+    </template>
+    <div class="w-64">
+      <ui-input
+        v-if="state.selectType === 'nodes'"
+        v-model="state.query"
+        placeholder="Search..."
+        class="w-full mb-4"
+      />
+      <template v-else>
+        <div
+          class="flex items-center cursor-pointer"
+          @click="state.selectType = 'nodes'"
+        >
+          <v-remixicon
+            name="riArrowLeftSLine"
+            title="Go back"
+            class="mr-1 -ml-1"
+          />
+          <span class="flex-1 text-overflow">
+            {{ getBlockName(selectedNode) }}
+          </span>
+        </div>
+        <p class="mt-2 mb-4">Select {{ type }}</p>
+      </template>
+      <ui-list class="space-y-1">
+        <ui-list-item
+          v-for="(item, index) in items"
+          :key="item.id"
+          :active="isActive(item)"
+          class="cursor-pointer"
+          @click="selectItem(item)"
+        >
+          <p class="text-overflow">
+            {{
+              state.selectType === 'nodes'
+                ? getBlockName(item, state.selectType)
+                : `${type} ${index + 1}`
+            }}
+          </p>
+        </ui-list-item>
+      </ui-list>
+      {{ data }}
+    </div>
+  </ui-popover>
+</template>
+<script setup>
+/* eslint-disable */
+import { reactive, computed, onMounted } from 'vue';
+import { tasks } from '@/utils/shared';
+
+const props = defineProps({
+  nodes: {
+    type: Array,
+    default: () => [],
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  type: {
+    type: String,
+    default: 'inputs',
+  },
+});
+const emit = defineEmits(['update']);
+
+const handleType = props.type === 'inputs' ? 'target' : 'source';
+
+const state = reactive({
+  query: '',
+  selectType: 'nodes',
+});
+
+const includeQuery = (str) =>
+  str.toLocaleLowerCase().includes(state.query.toLocaleLowerCase());
+
+const selectedNode = computed(() =>
+  props.nodes.find((node) => node.id === props.data.blockId)
+);
+const items = computed(() => {
+  const query = state.query.toLocaleLowerCase();
+
+  if (state.selectType === 'nodes') {
+    return props.nodes.filter(({ data, label }) => {
+      let additionalKey = false;
+
+      if (data.name) additionalKey = includeQuery(data.name);
+      else if (data.description) additionalKey = includeQuery(data.description);
+
+      return includeQuery(tasks[label]?.name || '') || additionalKey;
+    });
+  }
+
+  return selectedNode.value.handleBounds[handleType];
+});
+
+function updateData(data) {
+  emit('update', data);
+}
+function selectItem(item) {
+  if (state.selectType === 'nodes') {
+    const payload = { blockId: item.id };
+
+    if (props.data.blockId && props.data.blockId !== item.id) {
+      payload.handleId = '';
+    }
+
+    updateData(payload);
+    state.selectType = 'handle';
+  } else {
+    updateData({ handleId: item.id });
+  }
+}
+function getBlockName(item, type) {
+  const { label, data } = item;
+  let name = tasks[label]?.name || '';
+
+  if (data.name) name += ` (${data.name})`;
+  else if (data.description) name += ` (${data.description})`;
+
+  return name;
+}
+function isActive(item) {
+  if (state.selectType === 'nodes') {
+    return item.id === props.data.blockId;
+  }
+
+  return item.id === props.data.handleId;
+}
+
+onMounted(() => {
+  if (props.data.blockId) {
+    const blockExists = props.nodes.some(
+      (node) => node.id === props.data.blockId
+    );
+    if (blockExists) {
+      state.selectType = 'handle';
+    } else {
+      emit('update', { blockId: '', handleId: '' });
+    }
+  }
+});
+</script>

+ 222 - 0
src/components/newtab/package/PackageSettings.vue

@@ -0,0 +1,222 @@
+<template>
+  <label class="inline-flex items-center">
+    <ui-switch v-model="packageState.settings.asBlock" />
+    <span class="ml-4">
+      {{ $t('packages.settings.asBlock') }}
+    </span>
+  </label>
+  <div v-if="packageState.settings.asBlock" class="mt-6 pb-8 flex space-x-6">
+    <div class="flex-1">
+      <p class="font-semibold">Block inputs</p>
+      <div class="mt-4">
+        <div
+          v-if="packageState.inputs.length > 0"
+          class="grid grid-cols-12 gap-x-4"
+        >
+          <div class="col-span-5 pl-1 text-sm">Input name</div>
+          <div class="col-span-6 pl-1 text-sm">Block</div>
+        </div>
+        <draggable
+          v-model="packageState.inputs"
+          group="inputs"
+          handle=".handle"
+          item-key="id"
+        >
+          <template #item="{ element, index }">
+            <div
+              class="grid grid-cols-12 mb-2 relative gap-x-4 items-center group"
+            >
+              <span
+                class="absolute left-0 handle -ml-6 cursor-move invisible group-hover:visible"
+              >
+                <v-remixicon name="mdiDrag" />
+              </span>
+              <ui-input
+                v-model="element.name"
+                class="col-span-5"
+                :placeholder="`Input ${index + 1}`"
+              />
+              <div class="flex items-center col-span-6">
+                <ui-button
+                  v-tooltip="'Go to block'"
+                  class="mr-2"
+                  icon
+                  @click="$emit('goBlock', element.blockId)"
+                >
+                  <v-remixicon name="riFocus3Line" />
+                </ui-button>
+                <p
+                  :title="getBlockIOName('inputs', element)"
+                  class="text-overflow flex-1"
+                >
+                  {{ getBlockIOName('inputs', element) }}
+                </p>
+              </div>
+              <div class="col-span-1 text-right">
+                <v-remixicon
+                  name="riDeleteBin7Line"
+                  class="cursor-pointer text-gray-600 dark:text-gray-200 inline-block"
+                  @click="deleteBlockIo('inputs', index)"
+                />
+              </div>
+            </div>
+          </template>
+        </draggable>
+      </div>
+    </div>
+    <hr class="border-r" />
+    <div class="flex-1">
+      <p class="font-semibold">Block outputs</p>
+      <div class="mt-4">
+        <div
+          v-if="packageState.outputs.length > 0"
+          class="grid grid-cols-12 gap-x-4"
+        >
+          <div class="col-span-5 pl-1 text-sm">Output name</div>
+          <div class="col-span-6 pl-1 text-sm">Block</div>
+        </div>
+        <draggable
+          v-model="packageState.outputs"
+          group="outputs"
+          handle=".handle"
+          item-key="id"
+        >
+          <template #item="{ element, index }">
+            <div
+              class="grid grid-cols-12 mb-2 relative gap-x-4 items-center group"
+            >
+              <span
+                class="absolute left-0 handle -ml-6 cursor-move invisible group-hover:visible"
+              >
+                <v-remixicon name="mdiDrag" />
+              </span>
+              <ui-input
+                v-model="element.name"
+                class="col-span-5"
+                :placeholder="`Output ${index + 1}`"
+              />
+              <div class="flex items-center col-span-6">
+                <ui-button
+                  v-tooltip="'Go to block'"
+                  class="mr-2"
+                  icon
+                  @click="$emit('goBlock', element.blockId)"
+                >
+                  <v-remixicon name="riFocus3Line" />
+                </ui-button>
+                <p
+                  :title="getBlockIOName('outputs', element)"
+                  class="text-overflow flex-1"
+                >
+                  {{ getBlockIOName('outputs', element) }}
+                </p>
+              </div>
+              <div class="col-span-1 text-right">
+                <v-remixicon
+                  name="riDeleteBin7Line"
+                  class="cursor-pointer text-gray-600 dark:text-gray-200 inline-block"
+                  @click="deleteBlockIo('outputs', index)"
+                />
+              </div>
+            </div>
+          </template>
+        </draggable>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import cloneDeep from 'lodash.clonedeep';
+import Draggable from 'vuedraggable';
+import { tasks } from '@/utils/shared';
+import { debounce } from '@/utils/helper';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update', 'goBlock']);
+
+const state = reactive({
+  retrieved: false,
+});
+const packageState = reactive({
+  inputs: [],
+  outputs: [],
+  settings: { asBlock: false },
+});
+
+function deleteBlockIo(type, index) {
+  packageState[type].splice(index, 1);
+}
+
+const cacheIOName = new Map();
+
+function getNodeName({ label, data }) {
+  let name = tasks[label]?.name || '';
+
+  if (data.name) name += ` (${data.name})`;
+  else if (data.description) name += ` (${data.description})`;
+
+  return name;
+}
+function getBlockIOName(type, data) {
+  if (!props.editor) return '';
+
+  const cacheId = `${data.blockId}-${data.handleId}`;
+  if (cacheIOName.has(cacheId)) return cacheIOName.get(cacheId);
+
+  let name = '';
+
+  const node = props.editor.getNode.value(data.blockId);
+  if (!node) {
+    name = 'Block not found';
+  } else {
+    const nodeName = getNodeName(node);
+    const handleType = type === 'outputs' ? 'source' : 'target';
+    const index = node.handleBounds[handleType].findIndex(
+      (item) => item.id === data.handleId
+    );
+    const handleName =
+      index === -1 ? 'Not found' : `${type.slice(0, -1)} ${index + 1}`;
+
+    name = `${nodeName} > ${handleName}`;
+  }
+
+  cacheIOName.set(cacheId, name);
+
+  return name;
+}
+
+watch(
+  packageState,
+  debounce(() => {
+    if (state.retrieved) {
+      emit('update', packageState);
+    }
+  }, 500),
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(
+    packageState,
+    cloneDeep({
+      inputs: props.data.inputs,
+      outputs: props.data.outputs,
+      settings: props.data.settings || {},
+    })
+  );
+
+  setTimeout(() => {
+    state.retrieved = true;
+  }, 1000);
+});
+</script>

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

@@ -27,7 +27,7 @@
           </span>
         </div>
         <ui-input
-          :model-value="workflow.icon.startsWith('ri') ? '' : workflow.icon"
+          :model-value="workflow.icon.startsWith('http') ? workflow.icon : ''"
           type="url"
           placeholder="http://example.com/img.png"
           label="Icon URL"
@@ -111,6 +111,7 @@ const pinnedCategory = {
   color: 'bg-accent',
 };
 const icons = [
+  'mdiPackageVariantClosed',
   'riGlobalLine',
   'riFileTextLine',
   'riEqualizerLine',
@@ -125,7 +126,10 @@ const icons = [
   'riCommandLine',
 ];
 
-const blocksArr = Object.entries({ ...tasks, ...customBlocks }).map(
+const copyTasks = { ...tasks };
+delete copyTasks['block-package'];
+
+const blocksArr = Object.entries({ ...copyTasks, ...customBlocks }).map(
   ([key, block]) => {
     const localeKey = `workflow.blocks.${key}.name`;
 

+ 10 - 3
src/components/newtab/workflow/WorkflowEditor.vue

@@ -47,7 +47,10 @@
     <template v-for="(node, name) in nodeTypes" :key="name" #[name]="nodeProps">
       <component
         :is="node"
-        v-bind="nodeProps"
+        v-bind="{
+          ...nodeProps,
+          editor: name === 'node-BlockPackage' ? editor : null,
+        }"
         @delete="deleteBlock"
         @edit="editBlock(nodeProps, $event)"
         @update="updateBlockData(nodeProps.id, $event)"
@@ -124,6 +127,7 @@ const nodeTypes = blockComponents.keys().reduce((acc, key) => {
   return acc;
 }, {});
 const getPosition = (position) => (Array.isArray(position) ? position : [0, 0]);
+const setMinValue = (num, min) => (num < min ? min : num);
 
 const { t } = useI18n();
 const store = useStore();
@@ -133,8 +137,11 @@ const editor = useVueFlow({
   deleteKeyCode: 'Delete',
   elevateEdgesOnSelect: true,
   defaultZoom: props.data?.zoom ?? 1,
-  minZoom: Math.abs(+store.settings.editor.minZoom || 0.5),
-  maxZoom: Math.abs(+store.settings.editor.maxZoom || 1.2),
+  minZoom: setMinValue(+store.settings.editor.minZoom || 0.5, 0.1),
+  maxZoom: setMinValue(
+    +store.settings.editor.maxZoom || 1.2,
+    +store.settings.editor.minZoom + 0.1
+  ),
   multiSelectionKeyCode: isMac ? 'Meta' : 'Control',
   defaultPosition: getPosition(props.data?.position),
   ...props.options,

+ 1 - 6
src/components/newtab/workflow/edit/EditLoopData.vue

@@ -17,12 +17,7 @@
       :model-value="data.loopThrough"
       :label="t('workflow.blocks.loop-data.loopThrough.placeholder')"
       class="w-full"
-      @change="
-        updateData({
-          loopThrough: $event,
-          loopData: $event === 'custom-data' ? data.loopData : '[]',
-        })
-      "
+      @change="updateData({ loopThrough: $event })"
     >
       <option v-for="type in loopTypes" :key="type" :value="type">
         {{ t(`workflow.blocks.loop-data.loopThrough.options.${type}`) }}

+ 119 - 0
src/components/newtab/workflow/editor/EditorAddPackage.vue

@@ -0,0 +1,119 @@
+<template>
+  <div class="flex items-center">
+    <ui-popover v-tooltip:bottom="t('packages.icon')" class="mr-2">
+      <template #trigger>
+        <img
+          v-if="state.icon.startsWith('http')"
+          :src="state.icon"
+          width="38px"
+          height="38px"
+          class="rounded-lg"
+        />
+        <span
+          v-else
+          icon
+          class="inline-block p-2 bg-box-transparent rounded-lg"
+        >
+          <v-remixicon :name="state.icon || 'mdiPackageVariantClosed'" />
+        </span>
+      </template>
+      <div class="w-64">
+        <p>{{ t('packages.icon') }}</p>
+        <div class="mt-4 gap-2 grid grid-cols-6">
+          <div v-for="icon in icons" :key="icon">
+            <span
+              :class="{ 'bg-box-transparent': icon === state.icon }"
+              class="inline-block p-2 cursor-pointer hoverable rounded-lg"
+              @click="state.icon = icon"
+            >
+              <v-remixicon :name="icon" />
+            </span>
+          </div>
+        </div>
+        <ui-input
+          :model-value="state.icon.startsWith('http') ? state.icon : ''"
+          type="url"
+          placeholder="http://example.com/img.png"
+          label="Icon URL"
+          class="mt-2 w-full"
+          @change="updatePackageIcon"
+        />
+      </div>
+    </ui-popover>
+    <ui-input
+      v-model="state.name"
+      :placeholder="t('common.name')"
+      autofocus
+      class="w-full"
+      @keyup.enter="$emit('add')"
+    />
+  </div>
+  <ui-textarea
+    v-model="state.description"
+    :label="t('common.description')"
+    placeholder="Description..."
+    class="w-full mt-4"
+  />
+  <div class="flex items-center justify-end space-x-4 mt-6">
+    <ui-button @click="$emit('cancel')">
+      {{ t('common.cancel') }}
+    </ui-button>
+    <ui-button variant="accent" class="w-20" @click="$emit('add')">
+      {{ t('common.add') }}
+    </ui-button>
+  </div>
+</template>
+<script setup>
+import { reactive, watch, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update', 'add', 'cancel']);
+
+const icons = [
+  'mdiPackageVariantClosed',
+  'riGlobalLine',
+  'riFileTextLine',
+  'riEqualizerLine',
+  'riTimerLine',
+  'riCalendarLine',
+  'riFlashlightLine',
+  'riLightbulbFlashLine',
+  'riDatabase2Line',
+  'riWindowLine',
+  'riCursorLine',
+  'riDownloadLine',
+  'riCommandLine',
+];
+
+const { t } = useI18n();
+
+const state = reactive({
+  name: '',
+  icon: '',
+  description: '',
+});
+
+function updatePackageIcon(value) {
+  if (!value.startsWith('http')) return;
+
+  state.icon = value.slice(0, 1024);
+}
+
+watch(
+  state,
+  () => {
+    emit('update', state);
+  },
+  { deep: true }
+);
+
+onMounted(() => {
+  Object.assign(state, props.data);
+});
+</script>

+ 19 - 7
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -259,7 +259,7 @@
       @close="state.showEditDescription = false"
     />
   </ui-modal>
-  <ui-modal v-model="renameState.showModal" title="Workflow">
+  <ui-modal v-model="renameState.showModal" title="Rename">
     <ui-input
       v-model="renameState.name"
       :placeholder="t('common.name')"
@@ -299,7 +299,7 @@
       class="bg-box-transparent p-4 rounded-lg overflow-auto scroll"
       placeholder="Write note here..."
       style="max-height: calc(100vh - 12rem); min-height: 400px"
-      @change="updateWorkflow({ content: $event }, true)"
+      @change="updateWorkflowNote({ content: $event })"
     />
   </ui-modal>
 </template>
@@ -315,11 +315,12 @@ import { useUserStore } from '@/stores/user';
 import { useWorkflowStore } from '@/stores/workflow';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useSharedWorkflowStore } from '@/stores/sharedWorkflow';
+import { usePackageStore } from '@/stores/package';
 import { useDialog } from '@/composable/dialog';
 import { useGroupTooltip } from '@/composable/groupTooltip';
 import { useShortcut, getShortcut } from '@/composable/shortcut';
 import { tagColors } from '@/utils/shared';
-import { parseJSON, findTriggerBlock } from '@/utils/helper';
+import { parseJSON, findTriggerBlock, debounce } from '@/utils/helper';
 import { exportWorkflow, convertWorkflow } from '@/utils/workflowData';
 import { registerWorkflowTrigger } from '@/utils/workflowTrigger';
 import getTriggerText from '@/utils/triggerText';
@@ -349,6 +350,7 @@ const props = defineProps({
     default: true,
   },
   isTeam: Boolean,
+  isPackage: Boolean,
 });
 const emit = defineEmits(['modal', 'change', 'update', 'permission']);
 
@@ -359,6 +361,7 @@ const toast = useToast();
 const router = useRouter();
 const dialog = useDialog();
 const userStore = useUserStore();
+const packageStore = usePackageStore();
 const workflowStore = useWorkflowStore();
 const teamWorkflowStore = useTeamWorkflowStore();
 const sharedWorkflowStore = useSharedWorkflowStore();
@@ -395,6 +398,11 @@ const userDontHaveTeamsAccess = computed(() => {
   );
 });
 
+const updateWorkflowNote = debounce((data) => {
+  /* eslint-disable-next-line */
+  updateWorkflow(data, true);
+}, 200);
+
 function updateWorkflow(data = {}, changedIndicator = false) {
   let store = null;
 
@@ -622,17 +630,21 @@ function renameWorkflow() {
 }
 function deleteWorkflow() {
   dialog.confirm({
-    title: t('workflow.delete'),
+    title: props.isPackage ? t('common.delete') : t('workflow.delete'),
     okVariant: 'danger',
-    body: t('message.delete', { name: props.workflow.name }),
+    body: props.isPackage
+      ? `Are you sure want to delete "${props.workflow.name}" package?`
+      : t('message.delete', { name: props.workflow.name }),
     onConfirm: async () => {
-      if (props.isTeam) {
+      if (props.isPackage) {
+        await packageStore.delete(props.workflow.id);
+      } else if (props.isTeam) {
         await teamWorkflowStore.delete(teamId, props.workflow.id);
       } else {
         await workflowStore.delete(props.workflow.id);
       }
 
-      router.replace('/');
+      router.replace(props.isPackage ? '/packages' : '/');
     },
   });
 }

+ 38 - 5
src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue

@@ -37,14 +37,17 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  packageIo: Boolean,
+  isPackage: Boolean,
 });
 const emit = defineEmits([
   'copy',
   'paste',
-  'duplicate',
   'group',
   'ungroup',
   'saveBlock',
+  'duplicate',
+  'packageIo',
 ]);
 
 const { t } = useI18n();
@@ -75,7 +78,7 @@ const menuItems = {
   },
   saveToFolder: {
     id: 'saveToFolder',
-    name: t('workflow.blocksFolder.save'),
+    name: t('packages.set'),
     event: () => {
       emit('saveBlock', ctxData);
     },
@@ -106,6 +109,16 @@ const menuItems = {
     event: () => emit('duplicate', ctxData),
     shortcut: getShortcut('editor:duplicate-block').readable,
   },
+  setAsInput: {
+    id: 'setAsInput',
+    name: 'Set as block input',
+    event: () => emit('packageIo', { type: 'inputs', ...ctxData }),
+  },
+  setAsOutput: {
+    id: 'setAsOutput',
+    name: 'Set as block output',
+    event: () => emit('packageIo', { type: 'outputs', ...ctxData }),
+  },
 };
 
 /* eslint-disable-next-line */
@@ -113,8 +126,11 @@ function showCtxMenu(items = [], event) {
   event.preventDefault();
   const { clientX, clientY } = event;
 
-  state.items = items.map((key) => markRaw(menuItems[key]));
+  if (props.isPackage && items.includes('saveToFolder')) {
+    items.splice(items.indexOf('saveToFolder'), 1);
+  }
 
+  state.items = items.map((key) => markRaw(menuItems[key]));
   state.items.unshift(markRaw(menuItems.paste));
 
   state.position = {
@@ -144,12 +160,29 @@ onMounted(() => {
       items.splice(3, 0, 'group');
     }
 
-    showCtxMenu(items, event);
-    ctxData = {
+    const currCtxData = {
       edges: [],
       nodes: [node],
       position: { clientX: event.clientX, clientY: event.clientY },
     };
+
+    if (
+      props.isPackage &&
+      props.packageIo &&
+      event.target.closest('[data-handleid]')
+    ) {
+      const { handleid, nodeid } = event.target.dataset;
+
+      currCtxData.nodeId = nodeid;
+      currCtxData.handleId = handleid;
+
+      items.unshift(
+        event.target.classList.contains('source') ? 'setAsOutput' : 'setAsInput'
+      );
+    }
+
+    showCtxMenu(items, event);
+    ctxData = currCtxData;
   });
   props.editor.onEdgeContextMenu(({ event, edge }) => {
     showCtxMenu(['delete'], event);

+ 61 - 103
src/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue

@@ -9,14 +9,6 @@
           autocomplete="off"
           prepend-icon="riSearch2Line"
         />
-        <ui-button
-          v-tooltip="'Refresh data'"
-          icon
-          class="ml-4"
-          @click="loadData"
-        >
-          <v-remixicon name="riRefreshLine" />
-        </ui-button>
         <div class="flex-grow" />
         <ui-button icon @click="$emit('close')">
           <v-remixicon name="riCloseLine" />
@@ -27,7 +19,7 @@
         style="min-height: 95px"
       >
         <p
-          v-if="state.savedBlocks.length === 0"
+          v-if="packageStore.packages.length === 0"
           class="py-8 w-full text-center"
         >
           {{ t('message.noData') }}
@@ -36,28 +28,58 @@
           v-for="item in items"
           :key="item.id"
           draggable="true"
-          class="p-4 rounded-lg flex-shrink-0 border-2 cursor-move hoverable flex flex-col relative transition"
-          style="width: 220px"
+          class="rounded-lg flex-shrink-0 border-2 cursor-move hoverable flex flex-col relative transition"
+          style="width: 288px; height: 125px"
           @dragstart="
             $event.dataTransfer.setData('savedBlocks', JSON.stringify(item))
           "
         >
-          <p class="font-semibold text-overflow leading-tight">
-            {{ item.name }}
-          </p>
-          <p
-            class="text-gray-600 dark:text-gray-200 line-clamp leading-tight flex-1"
-          >
-            {{ item.description }}
-          </p>
+          <div class="flex items-start p-4 flex-1">
+            <div class="w-8 flex-shrink-0">
+              <img
+                v-if="item.icon.startsWith('http')"
+                :src="item.icon"
+                width="38px"
+                height="38px"
+                class="rounded-lg"
+              />
+              <v-remixicon
+                v-else
+                :name="item.icon || 'mdiPackageVariantClosed'"
+              />
+            </div>
+            <div class="flex-1 overflow-hidden">
+              <p class="font-semibold text-overflow leading-tight">
+                {{ item.name }}
+              </p>
+              <p
+                class="text-gray-600 dark:text-gray-200 line-clamp leading-tight"
+              >
+                {{ item.description }}
+              </p>
+            </div>
+          </div>
           <div
-            class="space-x-3 mt-2 text-gray-600 dark:text-gray-200 flex justify-end"
+            class="space-x-3 pb-4 px-4 text-gray-600 dark:text-gray-200 flex items-center"
           >
+            <span v-if="item.author" class="text-overflow">
+              By {{ item.author }}
+            </span>
+            <div class="flex-grow" />
+            <a
+              v-if="item.isExternal"
+              :href="`https://automa.site/packages/${item.id}`"
+              target="_blank"
+              title="Open package page"
+            >
+              <v-remixicon name="riExternalLinkLine" size="18" />
+            </a>
             <v-remixicon
+              v-else
               name="riPencilLine"
               size="18"
               class="cursor-pointer"
-              @click="initEditState(item)"
+              @click="$router.push(`/packages/${item.id}`)"
             />
             <v-remixicon
               name="riDeleteBin7Line"
@@ -69,108 +91,44 @@
         </div>
       </div>
     </ui-card>
-    <ui-modal v-model="editState.show" :title="t('common.message')">
-      <ui-input
-        v-model="editState.name"
-        :placeholder="t('common.name')"
-        autofocus
-        class="w-full"
-        @keyup.enter="saveEdit"
-      />
-      <ui-textarea
-        v-model="editState.description"
-        :label="t('common.description')"
-        placeholder="Description..."
-        class="w-full mt-4"
-      />
-      <div class="flex items-center justify-end space-x-4 mt-6">
-        <ui-button @click="clearEditState">
-          {{ t('common.cancel') }}
-        </ui-button>
-        <ui-button variant="accent" class="w-20" @click="saveEdit">
-          {{ t('common.save') }}
-        </ui-button>
-      </div>
-    </ui-modal>
   </div>
 </template>
 <script setup>
-import { onMounted, reactive, computed, toRaw } from 'vue';
+import { computed, reactive } from 'vue';
 import { useI18n } from 'vue-i18n';
-import browser from 'webextension-polyfill';
+import { useDialog } from '@/composable/dialog';
+import { usePackageStore } from '@/stores/package';
 
 defineEmits(['close']);
 
 const { t } = useI18n();
+const dialog = useDialog();
+const packageStore = usePackageStore();
 
 const state = reactive({
   query: '',
-  savedBlocks: [],
-});
-const editState = reactive({
-  id: '',
-  name: '',
-  show: false,
-  description: '',
 });
 
+const sortedItems = computed(() =>
+  packageStore.packages.slice().sort((a, b) => b.createdAt - a.createdAt)
+);
 const items = computed(() => {
   const query = state.query.toLocaleLowerCase();
 
-  return state.savedBlocks.filter((item) =>
+  return sortedItems.value.filter((item) =>
     item.name.toLocaleLowerCase().includes(query)
   );
 });
 
-function initEditState(item) {
-  Object.assign(editState, {
-    show: true,
-    id: item.id,
-    name: item.name,
-    description: item.description,
-  });
-}
-function clearEditState() {
-  Object.assign(editState, {
-    id: '',
-    name: '',
-    show: false,
-    description: '',
+function deleteItem({ id, name }) {
+  dialog.confirm({
+    title: 'Delete package',
+    body: `Are you sure want to delete "${name}" package?`,
+    okText: 'Delete',
+    okVariant: 'danger',
+    onConfirm: () => {
+      packageStore.delete(id);
+    },
   });
 }
-async function saveEdit() {
-  try {
-    const index = state.savedBlocks.findIndex(
-      (item) => item.id === editState.id
-    );
-    Object.assign(state.savedBlocks[index], {
-      name: editState.name,
-      description: editState.description,
-    });
-
-    browser.storage.local.set({
-      savedBlocks: toRaw(state.savedBlocks),
-    });
-
-    clearEditState();
-  } catch (error) {
-    console.error(error);
-  }
-}
-function loadData() {
-  browser.storage.local.get('savedBlocks').then((storage) => {
-    state.savedBlocks = storage.savedBlocks || [];
-  });
-}
-function deleteItem({ id }) {
-  const index = state.savedBlocks.findIndex((item) => item.id === id);
-  if (index === -1) return;
-
-  state.savedBlocks.splice(index, 1);
-  browser.storage.local.set({
-    savedBlocks: toRaw(state.savedBlocks),
-  });
-}
-
-onMounted(loadData);
 </script>

+ 275 - 0
src/components/newtab/workflow/editor/EditorPkgActions.vue

@@ -0,0 +1,275 @@
+<template>
+  <ui-card
+    v-if="userStore.user"
+    class="pointer-events-auto space-x-1 mr-2"
+    padding="p-1"
+  >
+    <ui-popover>
+      <template #trigger>
+        <ui-button
+          :class="{ 'text-primary': isPkgShared }"
+          icon
+          type="transparent"
+        >
+          <v-remixicon name="riShareLine" />
+        </ui-button>
+      </template>
+      <div class="w-64">
+        <div class="flex items-center">
+          <p class="flex-1">Share package</p>
+          <ui-spinner
+            v-if="state.isSharing || state.isLoadData"
+            color="text-accent"
+          />
+          <ui-switch
+            v-else
+            v-tooltip:bottom="
+              isPkgShared ? 'Unpublish package' : 'Share package'
+            "
+            :model-value="isPkgShared"
+            @change="toggleSharePackage"
+          />
+        </div>
+        <transition-expand>
+          <ui-input
+            v-if="isPkgShared"
+            :model-value="`https://automa.site/packages/${data.id}`"
+            readonly
+            title="URL"
+            type="url"
+            class="w-full mt-2"
+            @click="$event.target.select()"
+          />
+        </transition-expand>
+      </div>
+    </ui-popover>
+  </ui-card>
+  <ui-card class="pointer-events-auto flex items-center" padding="p-1">
+    <ui-popover>
+      <template #trigger>
+        <ui-button icon type="transparent">
+          <v-remixicon name="riMore2Line" />
+        </ui-button>
+      </template>
+      <ui-list class="space-y-1" style="min-width: 9rem">
+        <ui-list-item
+          v-close-popover
+          class="text-red-400 dark:text-red-500 cursor-pointer"
+          @click="deletePackage"
+        >
+          <v-remixicon name="riDeleteBin7Line" class="mr-2 -ml-1" />
+          <span>
+            {{ t('common.delete') }}
+          </span>
+        </ui-list-item>
+      </ui-list>
+    </ui-popover>
+    <ui-button
+      :title="shortcuts['editor:save'].readable"
+      :variant="isPkgShared ? 'default' : 'accent'"
+      class="relative ml-1"
+      @click="savePackage"
+    >
+      <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-button
+      v-if="isPkgShared"
+      :loading="state.isUpdating"
+      variant="accent"
+      class="ml-4"
+      @click="updateSharedPackage"
+    >
+      {{ $t('common.update') }}
+    </ui-button>
+  </ui-card>
+</template>
+<script setup>
+import { onMounted, computed, reactive } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useRouter } from 'vue-router';
+import { useToast } from 'vue-toastification';
+import browser from 'webextension-polyfill';
+import { useUserStore } from '@/stores/user';
+import { usePackageStore } from '@/stores/package';
+import { getShortcut, useShortcut } from '@/composable/shortcut';
+import { useDialog } from '@/composable/dialog';
+import { fetchApi } from '@/utils/api';
+
+const props = defineProps({
+  isDataChanged: {
+    type: Boolean,
+    default: false,
+  },
+  data: {
+    type: Object,
+    default: () => ({}),
+  },
+  editor: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+const emit = defineEmits(['update']);
+
+const { t } = useI18n();
+const toast = useToast();
+const dialog = useDialog();
+const router = useRouter();
+const userStore = useUserStore();
+const packageStore = usePackageStore();
+const shortcuts = useShortcut([
+  /* eslint-disable-next-line */
+  getShortcut('editor:save', savePackage),
+]);
+
+const state = reactive({
+  isSharing: false,
+  isUpdating: false,
+  isLoadData: false,
+});
+
+const isPkgShared = computed(() => packageStore.isShared(props.data.id));
+
+function deletePackage() {
+  dialog.confirm({
+    okVariant: 'danger',
+    okText: 'Delete',
+    title: 'Delete package',
+    body: `Are you sure want to delete the "${props.data.name}" package?`,
+    onConfirm: () => {
+      packageStore.delete(props.data.id);
+      router.replace('/packages');
+    },
+  });
+}
+function updatePackage(data = {}, changedIndicator = false) {
+  return packageStore
+    .update({
+      data,
+      id: props.data.id,
+    })
+    .then((result) => {
+      emit('update', { data, changedIndicator });
+
+      return result;
+    });
+}
+function savePackage() {
+  const flow = props.editor.toObject();
+  flow.edges = flow.edges.map((edge) => {
+    delete edge.sourceNode;
+    delete edge.targetNode;
+
+    return edge;
+  });
+
+  updatePackage({ data: flow }, false);
+}
+async function toggleSharePackage() {
+  state.isSharing = true;
+
+  try {
+    if (!isPkgShared.value) {
+      const keys = [
+        'data',
+        'description',
+        'icon',
+        'id',
+        'content',
+        'inputs',
+        'outputs',
+        'name',
+        'settings',
+      ];
+      const payload = { extVersion: browser.runtime.getManifest().version };
+
+      keys.forEach((key) => {
+        payload[key] = props.data[key];
+      });
+
+      const response = await fetchApi('/packages', {
+        method: 'POST',
+        body: JSON.stringify({
+          package: payload,
+        }),
+      });
+      const data = await response.json();
+
+      if (!response.ok) throw new Error(data.message);
+
+      packageStore.insertShared(props.data.id);
+    } else {
+      const response = await fetchApi(`/packages/${props.data.id}`, {
+        method: 'DELETE',
+      });
+      const result = await response.json();
+
+      if (!response.ok) throw new Error(result.message);
+
+      packageStore.deleteShared(props.data.id);
+    }
+  } catch (error) {
+    console.error(error);
+    toast.error('Something went wrong');
+  } finally {
+    state.isSharing = false;
+  }
+}
+async function updateSharedPackage() {
+  try {
+    state.isUpdating = true;
+
+    const keys = [
+      'data',
+      'description',
+      'icon',
+      'content',
+      'inputs',
+      'outputs',
+      'name',
+      'settings',
+    ];
+    const payload = { extVersion: browser.runtime.getManifest().version };
+
+    keys.forEach((key) => {
+      payload[key] = props.data[key];
+    });
+
+    const response = await fetchApi(`/packages/${props.data.id}`, {
+      method: 'PATCH',
+      body: JSON.stringify({ package: payload }),
+    });
+    const result = await response.json();
+
+    if (!response.ok) throw new Error(result.message);
+  } catch (error) {
+    console.error(error);
+    toast.error('Something went wrong!');
+  } finally {
+    state.isUpdating = false;
+  }
+}
+
+onMounted(async () => {
+  try {
+    state.isLoadData = true;
+    await packageStore.loadShared();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    state.isLoadData = false;
+  }
+});
+</script>

+ 17 - 8
src/components/ui/UiButton.vue

@@ -4,7 +4,7 @@
     role="button"
     class="ui-button h-10 relative transition"
     :class="[
-      color ? color : variants[variant],
+      color ? color : variants[type][variant],
       icon ? 'p-2' : 'py-2 px-4',
       circle ? 'rounded-full' : 'rounded-lg',
       {
@@ -49,6 +49,10 @@ export default {
       type: String,
       default: 'button',
     },
+    type: {
+      type: String,
+      default: 'fill',
+    },
     variant: {
       type: String,
       default: 'default',
@@ -56,13 +60,18 @@ export default {
   },
   setup() {
     const variants = {
-      default: 'bg-input',
-      accent:
-        'bg-accent hover:bg-gray-700 dark:bg-gray-100 dark:hover:bg-gray-200 dark:text-black text-white',
-      primary:
-        'bg-primary text-white dark:bg-secondary dark:hover:bg-primary hover:bg-secondary',
-      danger:
-        'bg-red-400 text-white dark:bg-red-500 dark:hover:bg-red-500 hover:bg-red-400',
+      transparent: {
+        default: 'hoverable',
+      },
+      fill: {
+        default: 'bg-input',
+        accent:
+          'bg-accent hover:bg-gray-700 dark:bg-gray-100 dark:hover:bg-gray-200 dark:text-black text-white',
+        primary:
+          'bg-primary text-white dark:bg-secondary dark:hover:bg-primary hover:bg-secondary',
+        danger:
+          'bg-red-400 text-white dark:bg-red-500 dark:hover:bg-red-500 hover:bg-red-400',
+      },
     };
 
     return {

+ 4 - 1
src/content/blocksHandler/handlerTakeScreenshot.js

@@ -71,7 +71,10 @@ async function captureElement({ selector, tabId, options }) {
     throw error;
   }
 
-  element.scrollIntoView();
+  element.scrollIntoView({
+    block: 'center',
+    inline: 'center',
+  });
 
   await sleep(500);
 

+ 9 - 0
src/content/commandPalette/index.js

@@ -1,3 +1,4 @@
+import browser from 'webextension-polyfill';
 import initApp from './main';
 import injectAppStyles from '../injectAppStyles';
 
@@ -21,6 +22,14 @@ export default async function () {
     const isMainFrame = window.self === window.top;
     if (!isMainFrame) return;
 
+    const isInvalidURL = /.(json|xml)$/.test(window.location.pathname);
+    if (isInvalidURL) return;
+
+    const { automaShortcut } = await browser.storage.local.get(
+      'automaShortcut'
+    );
+    if (Array.isArray(automaShortcut) && automaShortcut.length === 0) return;
+
     await pageLoaded();
 
     const instanceExist = document.querySelector('automa-palette');

+ 3 - 0
src/content/index.js

@@ -220,6 +220,9 @@ function messageListener({ data, source }) {
 
               contextElement = null;
             }
+            if (!$ctxTextSelection) {
+              $ctxTextSelection = window.getSelection().toString();
+            }
 
             resolve({
               $ctxElSelector,

+ 43 - 0
src/content/services/webService.js

@@ -22,6 +22,13 @@ function initWebListener() {
 
   return { on };
 }
+function sendMessageBack(type, payload = {}) {
+  const event = new CustomEvent(`__automa-ext__${type}`, {
+    detail: payload,
+  });
+
+  window.dispatchEvent(event);
+}
 
 window.addEventListener('DOMContentLoaded', async () => {
   try {
@@ -146,6 +153,42 @@ window.addEventListener('DOMContentLoaded', async () => {
         })
       );
     });
+    webListener.on('add-package', async (data) => {
+      try {
+        const { savedBlocks } = await browser.storage.local.get('savedBlocks');
+        const packages = savedBlocks || [];
+
+        packages.push({ ...data.package, createdAt: Date.now() });
+
+        await browser.storage.local.set({ savedBlocks: packages });
+
+        sendMessage('dashboard:refresh-packages', '', 'background');
+      } catch (error) {
+        console.error(error);
+      }
+    });
+    webListener.on('update-package', async (data) => {
+      const { savedBlocks } = await browser.storage.local.get('savedBlocks');
+      const packages = savedBlocks || [];
+
+      const index = packages.findIndex((pkg) => pkg.id === data.id);
+      if (index === -1) return;
+
+      Object.assign(packages[index], data.package);
+
+      await browser.storage.local.set({ savedBlocks: packages });
+
+      sendMessage('dashboard:refresh-packages', '', 'background');
+    });
+    webListener.on('send-message', async ({ type, data }) => {
+      if (type === 'package-installed') {
+        const { savedBlocks } = await browser.storage.local.get('savedBlocks');
+        const packages = savedBlocks || [];
+        const isInstalled = packages.some((pkg) => pkg.id === data);
+
+        sendMessageBack(type, isInstalled);
+      }
+    });
   } catch (error) {
     console.error(error);
   }

+ 4 - 0
src/lib/vRemixicon.js

@@ -37,6 +37,7 @@ import {
   riMore2Line,
   riMouseLine,
   riFocusLine,
+  riFontSize2,
   riParagraph,
   riImageLine,
   riCloseLine,
@@ -167,6 +168,7 @@ export const icons = {
   riMore2Line,
   riMouseLine,
   riFocusLine,
+  riFontSize2,
   riParagraph,
   riImageLine,
   riCloseLine,
@@ -258,6 +260,8 @@ export const icons = {
   riLightbulbFlashLine,
   riIncreaseDecreaseLine,
   mdiEqual: 'M19,10H5V8H19V10M19,16H5V14H19V16Z',
+  mdiPackageVariantClosed:
+    'M21,16.5C21,16.88 20.79,17.21 20.47,17.38L12.57,21.82C12.41,21.94 12.21,22 12,22C11.79,22 11.59,21.94 11.43,21.82L3.53,17.38C3.21,17.21 3,16.88 3,16.5V7.5C3,7.12 3.21,6.79 3.53,6.62L11.43,2.18C11.59,2.06 11.79,2 12,2C12.21,2 12.41,2.06 12.57,2.18L20.47,6.62C20.79,6.79 21,7.12 21,7.5V16.5M12,4.15L10.11,5.22L16,8.61L17.96,7.5L12,4.15M6.04,7.5L12,10.85L13.96,9.75L8.08,6.35L6.04,7.5M5,15.91L11,19.29V12.58L5,9.21V15.91M19,15.91V9.21L13,12.58V19.29L19,15.91Z',
   mdiVariable:
     'M20.41,3C21.8,5.71 22.35,8.84 22,12C21.8,15.16 20.7,18.29 18.83,21L17.3,20C18.91,17.57 19.85,14.8 20,12C20.34,9.2 19.89,6.43 18.7,4L20.41,3M5.17,3L6.7,4C5.09,6.43 4.15,9.2 4,12C3.66,14.8 4.12,17.57 5.3,20L3.61,21C2.21,18.29 1.65,15.17 2,12C2.2,8.84 3.3,5.71 5.17,3M12.08,10.68L14.4,7.45H16.93L13.15,12.45L15.35,17.37H13.09L11.71,14L9.28,17.33H6.76L10.66,12.21L8.53,7.45H10.8L12.08,10.68Z',
   mdiRegex:

+ 1 - 0
src/locales/en/common.json

@@ -26,6 +26,7 @@
     "save": "Save",
     "data": "data",
     "stop": "Stop",
+    "packages": "Packages",
     "storage": "Storage",
     "editor": "Editor",
     "running": "Running",

+ 15 - 0
src/locales/en/newtab.json

@@ -8,6 +8,21 @@
     "text": "Get started by reading the documentation or browsing workflows in the Automa Marketplace.",
     "marketplace": "Marketplace"
   },
+  "packages": {
+    "name": "Package | Packages",
+    "add": "Add package",
+    "icon": "Package icon",
+    "open": "Open packages",
+    "new": "New package",
+    "set": "Set as a package",
+    "settings": {
+      "asBlock": "Set package as block"
+    },
+    "categories": {
+      "my": "My Packages",
+      "installed": "Installed Packages",
+    }
+  },
   "scheduledWorkflow": {
     "title": "Scheduled workflows",
     "nextRun": "Next run",

+ 8 - 0
src/newtab/App.vue

@@ -62,6 +62,7 @@ import browser from 'webextension-polyfill';
 import { useStore } from '@/stores/main';
 import { useUserStore } from '@/stores/user';
 import { useFolderStore } from '@/stores/folder';
+import { usePackageStore } from '@/stores/package';
 import { useWorkflowStore } from '@/stores/workflow';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import { useTheme } from '@/composable/theme';
@@ -94,6 +95,7 @@ const theme = useTheme();
 const router = useRouter();
 const userStore = useUserStore();
 const folderStore = useFolderStore();
+const packageStore = usePackageStore();
 const workflowStore = useWorkflowStore();
 const teamWorkflowStore = useTeamWorkflowStore();
 const sharedWorkflowStore = useSharedWorkflowStore();
@@ -187,6 +189,11 @@ window.addEventListener('storage', ({ key, newValue }) => {
   );
 });
 browser.runtime.onMessage.addListener(({ type, data }) => {
+  if (type === 'refresh-packages') {
+    packageStore.loadData(true);
+    return;
+  }
+
   if (type === 'workflow:added') {
     if (data.source === 'team') {
       teamWorkflowStore.loadData().then(() => {
@@ -213,6 +220,7 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
       workflowStore.loadData(),
       teamWorkflowStore.loadData(),
       hostedWorkflowStore.loadData(),
+      packageStore.loadData(),
     ]);
 
     await loadLocaleMessages(store.settings.locale, 'newtab');

+ 243 - 0
src/newtab/pages/Packages.vue

@@ -0,0 +1,243 @@
+<template>
+  <div class="container py-8 pb-4">
+    <h1 class="text-2xl font-semibold">
+      {{ $t('common.packages') }}
+    </h1>
+    <div class="mt-8 flex items-start">
+      <div class="w-60">
+        <ui-button
+          class="w-full"
+          variant="accent"
+          @click="addState.show = true"
+        >
+          <p>{{ t('packages.new') }}</p>
+        </ui-button>
+        <ui-list class="text-gray-600 dark:text-gray-200 mt-4 space-y-1">
+          <ui-list-item
+            v-for="cat in categories"
+            :key="cat.id"
+            :active="cat.id === state.activeCat"
+            class="cursor-pointer"
+            color="bg-box-transparent text-black dark:text-gray-100"
+            @click="state.activeCat = cat.id"
+          >
+            {{ cat.name }}
+          </ui-list-item>
+        </ui-list>
+      </div>
+      <div class="flex-1 ml-8">
+        <div class="flex items-center">
+          <ui-input
+            v-model="state.query"
+            prepend-icon="riSearch2Line"
+            :placeholder="t('common.search')"
+          />
+          <div class="flex-grow" />
+          <div class="flex items-center workflow-sort">
+            <ui-button
+              icon
+              class="rounded-r-none border-gray-300 dark:border-gray-700 border-r"
+              @click="
+                sortState.order = sortState.order === 'asc' ? 'desc' : 'asc'
+              "
+            >
+              <v-remixicon
+                :name="sortState.order === 'asc' ? 'riSortAsc' : 'riSortDesc'"
+              />
+            </ui-button>
+            <ui-select v-model="sortState.by" :placeholder="t('sort.sortBy')">
+              <option v-for="sort in sorts" :key="sort" :value="sort">
+                {{ t(`sort.${sort}`) }}
+              </option>
+            </ui-select>
+          </div>
+        </div>
+        <div class="mt-8 grid gap-4 grid-cols-3 2xl:grid-cols-4">
+          <ui-card
+            v-for="pkg in packages"
+            :key="pkg.id"
+            class="hover:ring-2 flex flex-col group hover:ring-accent dark:hover:ring-gray-200"
+          >
+            <div class="flex items-center">
+              <ui-img
+                v-if="pkg.icon?.startsWith('http')"
+                :src="pkg.icon"
+                class="overflow-hidden rounded-lg"
+                style="height: 40px; width: 40px"
+                alt="Can not display"
+              />
+              <span v-else class="p-2 rounded-lg bg-box-transparent">
+                <v-remixicon :name="pkg.icon || 'mdiPackageVariantClosed'" />
+              </span>
+              <div class="flex-grow" />
+              <ui-popover>
+                <template #trigger>
+                  <v-remixicon
+                    name="riMoreLine"
+                    class="text-gray-600 dark:text-gray-200 cursor-pointer"
+                  />
+                </template>
+                <ui-list class="space-y-1" style="min-width: 180px">
+                  <ui-list-item
+                    v-if="pkg.isExternal"
+                    v-close-popover
+                    :href="`https://automa.site/packages/${pkg.id}`"
+                    tag="a"
+                    target="_blank"
+                    class="cursor-pointer"
+                    @click="deletePackage(pkg)"
+                  >
+                    <v-remixicon name="riExternalLinkLine" class="mr-2 -ml-1" />
+                    <span>Open package page</span>
+                  </ui-list-item>
+                  <ui-list-item
+                    v-close-popover
+                    class="cursor-pointer text-red-500 dark:text-red-400"
+                    @click="deletePackage(pkg)"
+                  >
+                    <v-remixicon name="riDeleteBin7Line" class="mr-2 -ml-1" />
+                    <span>{{ t('common.delete') }}</span>
+                  </ui-list-item>
+                </ui-list>
+              </ui-popover>
+            </div>
+            <router-link
+              :to="`/packages/${pkg.isExternal ? '' : pkg.id}`"
+              class="mt-4 flex-1 cursor-pointer"
+            >
+              <p class="font-semibold text-overflow">
+                {{ pkg.name }}
+              </p>
+              <p
+                class="line-clamp text-gray-600 dark:text-gray-200 leading-tight"
+              >
+                {{ pkg.description }}
+              </p>
+            </router-link>
+            <div
+              class="flex items-center text-gray-600 dark:text-gray-200 mt-2"
+            >
+              <p>{{ dayjs(pkg.createdAt).fromNow() }}</p>
+              <p v-if="pkg.author" class="text-overflow flex-1 ml-4 text-right">
+                By {{ pkg.author }}
+              </p>
+            </div>
+          </ui-card>
+        </div>
+      </div>
+    </div>
+    <ui-modal
+      v-model="addState.show"
+      :title="t('packages.add')"
+      @close="clearNewPackage"
+    >
+      <ui-input
+        v-model="addState.name"
+        :placeholder="t('common.name')"
+        autofocus
+        class="w-full"
+        @keyup.enter="addPackage"
+      />
+      <ui-textarea
+        v-model="addState.description"
+        :placeholder="t('common.description')"
+        style="min-height: 200px"
+        class="w-full mt-2"
+      />
+      <div class="flex space-x-4 mt-6">
+        <ui-button class="flex-1" @click="clearNewPackage">
+          {{ t('common.cancel') }}
+        </ui-button>
+        <ui-button class="flex-1" variant="accent" @click="addPackage">
+          {{ t('packages.add') }}
+        </ui-button>
+      </div>
+    </ui-modal>
+  </div>
+</template>
+<script setup>
+import { reactive, computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useDialog } from '@/composable/dialog';
+import { usePackageStore } from '@/stores/package';
+import { arraySorter } from '@/utils/helper';
+import dayjs from '@/lib/dayjs';
+
+const { t } = useI18n();
+const dialog = useDialog();
+const packageStore = usePackageStore();
+
+const sorts = ['name', 'createdAt'];
+const categories = [
+  { id: 'all', name: t('common.all') },
+  { id: 'user-pkgs', name: t('packages.categories.my') },
+  { id: 'installed-pkgs', name: t('packages.categories.installed') },
+];
+
+const state = reactive({
+  query: '',
+  activeCat: 'all',
+});
+const sortState = reactive({
+  order: 'desc',
+  by: 'createdAt',
+});
+const addState = reactive({
+  show: false,
+  name: '',
+  description: '',
+});
+
+const packages = computed(() => {
+  const filtered = packageStore.packages.filter((item) => {
+    let isInCategory = true;
+    const query = item.name
+      .toLocaleLowerCase()
+      .includes(state.query.toLocaleLowerCase());
+
+    if (state.activeCat !== 'all') {
+      isInCategory =
+        state.activeCat === 'user-pkgs' ? !item.isExternal : item.isExternal;
+    }
+
+    return isInCategory && query;
+  });
+
+  return arraySorter({
+    data: filtered,
+    key: sortState.by,
+    order: sortState.order,
+  });
+});
+
+function deletePackage({ id, name }) {
+  dialog.confirm({
+    title: 'Delete package',
+    body: `Are you sure want to delete "${name}" package?`,
+    okVariant: 'danger',
+    okText: 'Delete',
+    onConfirm: () => {
+      packageStore.delete(id);
+    },
+  });
+}
+function clearNewPackage() {
+  Object.assign(addState, {
+    name: '',
+    show: false,
+    description: '',
+  });
+}
+async function addPackage() {
+  try {
+    await packageStore.insert({
+      name: addState.name.trim() || 'Unnamed',
+      description: addState.description,
+    });
+
+    clearNewPackage();
+  } catch (error) {
+    console.error(error);
+  }
+}
+</script>

+ 186 - 76
src/newtab/pages/workflows/[id].vue

@@ -70,7 +70,13 @@
             />
           </button>
           <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
-          <ui-tab value="logs" class="flex items-center">
+          <template v-if="isPackage">
+            <ui-tab value="package-details"> Details </ui-tab>
+            <ui-tab value="package-settings">
+              {{ t('common.settings') }}
+            </ui-tab>
+          </template>
+          <ui-tab v-else value="logs" class="flex items-center">
             {{ t('common.log', 2) }}
             <span
               v-if="workflowStates.length > 0"
@@ -92,11 +98,20 @@
         </ui-card>
         <div class="flex-grow pointer-events-none" />
         <editor-used-credentials v-if="editor" :editor="editor" />
+        <editor-pkg-actions
+          v-if="isPackage"
+          :editor="editor"
+          :data="workflow"
+          :is-data-changed="state.dataChanged"
+          @update="onActionUpdated"
+        />
         <editor-local-actions
+          v-else
           :editor="editor"
           :workflow="workflow"
           :is-data-changed="state.dataChanged"
           :is-team="isTeamWorkflow"
+          :is-package="isPackage"
           :can-edit="haveEditAccess"
           @update="onActionUpdated"
           @permission="checkWorkflowPermission"
@@ -105,16 +120,30 @@
       </div>
       <ui-tab-panels
         v-model="state.activeTab"
-        class="overflow-hidden h-full w-full"
+        :class="{ 'overflow-hidden': !state.activeTab.startsWith('package') }"
+        class="h-full w-full"
         @drop="onDropInEditor"
         @dragend="clearHighlightedElements"
         @dragover.prevent="onDragoverEditor"
       >
+        <template v-if="isPackage">
+          <ui-tab-panel value="package-details" class="pt-24 container">
+            <package-details :data="workflow" @update="updateWorkflow" />
+          </ui-tab-panel>
+          <ui-tab-panel value="package-settings" class="pt-24 container">
+            <package-settings
+              :data="workflow"
+              :editor="editor"
+              @update="updateWorkflow"
+              @goBlock="goToPkgBlock"
+            />
+          </ui-tab-panel>
+        </template>
         <ui-tab-panel cache value="editor" class="w-full">
           <workflow-editor
             v-if="state.workflowConverted"
             :id="route.params.id"
-            :data="workflow.drawflow"
+            :data="editorData"
             :disabled="isTeamWorkflow && !haveEditAccess"
             :class="{ 'animate-blocks': state.animateBlocks }"
             class="h-screen focus:outline-none workflow-editor"
@@ -153,11 +182,12 @@
                 </button>
               </ui-card>
               <button
-                v-tooltip="t('workflow.blocksFolder.title')"
+                v-if="!isPackage && haveEditAccess"
+                v-tooltip="t('packages.open')"
                 class="control-button hoverable ml-2"
                 @click="blockFolderModal.showList = !blockFolderModal.showList"
               >
-                <v-remixicon name="riFolderOpenLine" />
+                <v-remixicon name="mdiPackageVariantClosed" />
               </button>
               <button
                 v-tooltip="t('workflow.autoAlign.title')"
@@ -175,12 +205,15 @@
           <editor-local-ctx-menu
             v-if="editor"
             :editor="editor"
+            :is-package="isPackage"
+            :package-io="workflow.settings?.asBlock"
             @group="groupBlocks"
             @ungroup="ungroupBlocks"
             @copy="copySelectedElements"
             @paste="pasteCopiedElements"
             @saveBlock="initBlockFolder"
             @duplicate="duplicateElements"
+            @packageIo="addPackageIO"
           />
         </ui-tab-panel>
         <ui-tab-panel value="logs" class="mt-24 container">
@@ -222,31 +255,17 @@
     :permissions="permissionState.items"
     @granted="registerTrigger"
   />
-  <ui-modal
-    v-model="blockFolderModal.showModal"
-    :title="t('workflow.blocksFolder.add')"
-  >
-    <ui-input
-      v-model="blockFolderModal.name"
-      :placeholder="t('common.name')"
-      autofocus
-      class="w-full"
-      @keyup.enter="saveBlockToFolder"
-    />
-    <ui-textarea
-      v-model="blockFolderModal.description"
-      :label="t('common.description')"
-      placeholder="Description..."
-      class="w-full mt-4"
+  <ui-modal v-model="blockFolderModal.showModal" :title="t('packages.set')">
+    <editor-add-package
+      :data="{
+        name: blockFolderModal.name,
+        description: blockFolderModal.description,
+        icon: blockFolderModal.icon,
+      }"
+      @update="Object.assign(blockFolderModal, $event)"
+      @cancel="clearBlockFolderModal"
+      @add="saveBlockToFolder"
     />
-    <div class="flex items-center justify-end space-x-4 mt-6">
-      <ui-button @click="clearBlockFolderModal">
-        {{ t('common.cancel') }}
-      </ui-button>
-      <ui-button variant="accent" class="w-20" @click="saveBlockToFolder">
-        {{ t('common.add') }}
-      </ui-button>
-    </div>
   </ui-modal>
 </template>
 <script setup>
@@ -268,6 +287,7 @@ import { useToast } from 'vue-toastification';
 import defu from 'defu';
 import dagre from 'dagre';
 import { useUserStore } from '@/stores/user';
+import { usePackageStore } from '@/stores/package';
 import { useWorkflowStore } from '@/stores/workflow';
 import { useTeamWorkflowStore } from '@/stores/teamWorkflow';
 import {
@@ -297,12 +317,16 @@ import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vu
 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 EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
 import SharedPermissionsModal from '@/components/newtab/shared/SharedPermissionsModal.vue';
+import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
+import EditorAddPackage from '@/components/newtab/workflow/editor/EditorAddPackage.vue';
+import EditorPkgActions from '@/components/newtab/workflow/editor/EditorPkgActions.vue';
 import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
 import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
 import EditorUsedCredentials from '@/components/newtab/workflow/editor/EditorUsedCredentials.vue';
 import EditorLocalSavedBlocks from '@/components/newtab/workflow/editor/EditorLocalSavedBlocks.vue';
+import PackageDetails from '@/components/newtab/package/PackageDetails.vue';
+import PackageSettings from '@/components/newtab/package/PackageSettings.vue';
 
 const blocks = { ...tasks, ...customBlocks };
 
@@ -317,12 +341,14 @@ const toast = useToast();
 const route = useRoute();
 const router = useRouter();
 const userStore = useUserStore();
+const packageStore = usePackageStore();
 const workflowStore = useWorkflowStore();
 const commandManager = useCommandManager();
 const teamWorkflowStore = useTeamWorkflowStore();
 
 const { teamId, id: workflowId } = route.params;
 const isTeamWorkflow = route.name === 'team-workflows';
+const isPackage = route.name === 'packages-details';
 
 const editor = shallowRef(null);
 const connectedTable = shallowRef(null);
@@ -337,6 +363,7 @@ const state = reactive({
 });
 const blockFolderModal = reactive({
   name: '',
+  icon: '',
   nodes: [],
   description: '',
   showList: false,
@@ -473,6 +500,9 @@ const workflow = computed(() => {
   if (isTeamWorkflow) {
     return teamWorkflowStore.getById(teamId, workflowId);
   }
+  if (isPackage) {
+    return packageStore.getById(workflowId);
+  }
 
   return workflowStore.getById(workflowId);
 });
@@ -489,6 +519,11 @@ const workflowColumns = computed(() => {
 
   return workflow.value.table;
 });
+const editorData = computed(() => {
+  if (isPackage) return workflow.value.data;
+
+  return workflow.value.drawflow;
+});
 
 provide('workflow', {
   editState,
@@ -620,6 +655,49 @@ const onEdgesChange = debounce((changes) => {
   // if (command) commandManager.add(command);
 }, 250);
 
+function goToBlock(blockId) {
+  if (!editor.value) return;
+
+  const block = editor.value.getNode.value(blockId);
+  if (!block) return;
+
+  editor.value.addSelectedNodes([block]);
+  setTimeout(() => {
+    const editorContainer = document.querySelector('.vue-flow');
+    const { height, width } = editorContainer.getBoundingClientRect();
+    const { x, y } = block.position;
+
+    editor.value.setTransform({
+      y: -(y - height / 2),
+      x: -(x - width / 2) - 200,
+      zoom: 1,
+    });
+  }, 200);
+}
+function goToPkgBlock(blockId) {
+  state.activeTab = 'editor';
+  goToBlock(blockId);
+}
+function addPackageIO({ type, handleId, nodeId }) {
+  const copyPkgIO = [...workflow.value[type]];
+  const itemExist = copyPkgIO.some(
+    (io) => io.blockId === nodeId && handleId === io.handleId
+  );
+  if (itemExist) {
+    toast.error(`You already add this as an ${type.slice(0, -1)}`);
+    return;
+  }
+
+  copyPkgIO.push({
+    handleId,
+    name: '',
+    id: nanoid(),
+    blockId: nodeId,
+  });
+
+  /* eslint-disable-next-line */
+  updateWorkflow({ [type]: copyPkgIO });
+}
 function initBlockFolder({ nodes }) {
   Object.assign(blockFolderModal, {
     nodes,
@@ -630,15 +708,14 @@ function clearBlockFolderModal() {
   Object.assign(blockFolderModal, {
     name: '',
     nodes: [],
-    showModal: false,
+    asBlock: false,
     description: '',
+    showModal: false,
+    icon: 'mdiPackageVariantClosed',
   });
 }
 async function saveBlockToFolder() {
   try {
-    let { savedBlocks } = await browser.storage.local.get('savedBlocks');
-    if (!savedBlocks) savedBlocks = [];
-
     const seen = new Set();
     const nodeList = [
       ...editor.value.getSelectedNodes.value,
@@ -646,8 +723,8 @@ async function saveBlockToFolder() {
     ].reduce((acc, node) => {
       if (seen.has(node.id)) return acc;
 
-      const { label, data, position, id } = node;
-      acc.push(cloneDeep({ label, data, position, id }));
+      const { label, data, position, id, type } = node;
+      acc.push(cloneDeep({ label, data, position, id, type }));
       seen.add(node.id);
 
       return acc;
@@ -657,15 +734,14 @@ async function saveBlockToFolder() {
         cloneDeep({ id, source, target, targetHandle, sourceHandle })
     );
 
-    savedBlocks.push({
-      id: nanoid(5),
+    packageStore.insert({
       data: { nodes: nodeList, edges },
       name: blockFolderModal.name || 'unnamed',
       description: blockFolderModal.description,
+      asBlock: blockFolderModal?.asBlock ?? false,
+      icon: blockFolderModal.icon || 'mdiPackageVariantClosed',
     });
 
-    await browser.storage.local.set({ savedBlocks });
-
     clearBlockFolderModal();
   } catch (error) {
     console.error(error);
@@ -776,7 +852,7 @@ async function initAutocomplete() {
     autocompleteState.blocks = objData;
   } else {
     const autocompleteData = {};
-    workflow.value.drawflow.nodes.forEach(({ label, id, data }) => {
+    editorData.value.nodes.forEach(({ label, id, data }) => {
       Object.assign(
         autocompleteData,
         extractAutocopmleteData(label, { data, id })
@@ -802,7 +878,7 @@ async function initAutocomplete() {
   }
 }
 function registerTrigger() {
-  const triggerBlock = workflow.value.drawflow.nodes.find(
+  const triggerBlock = editorData.value.nodes.find(
     (node) => node.label === 'trigger'
   );
   registerWorkflowTrigger(workflowId, triggerBlock);
@@ -833,6 +909,15 @@ function onNodesChange(changes) {
       state.dataChanged = true;
       nodeChanges.removed.push(id);
     } else if (type === 'add') {
+      if (isPackage) {
+        const excludeBlocks = ['block-package', 'trigger'];
+        if (excludeBlocks.includes(item.label)) {
+          editor.value.removeNodes([item]);
+        }
+
+        return;
+      }
+
       nodeChanges.added.push(item);
     }
   });
@@ -953,6 +1038,15 @@ function initEditBlock(data) {
 }
 async function updateWorkflow(data) {
   try {
+    if (isPackage) {
+      delete data.drawflow;
+      await packageStore.update({
+        id: workflowId,
+        data,
+      });
+      return;
+    }
+
     if (isTeamWorkflow) {
       if (!haveEditAccess.value && !data.globalData) return;
       await teamWorkflowStore.update({
@@ -976,8 +1070,9 @@ async function updateWorkflow(data) {
 }
 function onActionUpdated({ data, changedIndicator }) {
   state.dataChanged = changedIndicator;
+
   workflowPayload.data = { ...workflowPayload.data, ...data };
-  updateHostedWorkflow();
+  if (!isPackage) updateHostedWorkflow();
 }
 function onEditorInit(instance) {
   editor.value = instance;
@@ -1019,23 +1114,7 @@ function onEditorInit(instance) {
   }, 1000);
 
   const { blockId } = route.query;
-  if (blockId) {
-    const block = instance.getNode.value(blockId);
-    if (!block) return;
-
-    instance.addSelectedNodes([block]);
-    setTimeout(() => {
-      const editorContainer = document.querySelector('.vue-flow');
-      const { height, width } = editorContainer.getBoundingClientRect();
-      const { x, y } = block.position;
-
-      instance.setTransform({
-        y: -(y - height / 2),
-        x: -(x - width / 2) - 200,
-        zoom: 1,
-      });
-    }, 200);
-  }
+  if (blockId) goToBlock(blockId);
 }
 function clearHighlightedElements() {
   const elements = document.querySelectorAll(
@@ -1075,13 +1154,29 @@ function onDragoverEditor({ target }) {
 }
 function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
   const savedBlocks = parseJSON(dataTransfer.getData('savedBlocks'), null);
-  if (savedBlocks) {
-    const { nodes, edges } = savedBlocks.data;
-    /* eslint-disable-next-line */
-    const newElements = copyElements(nodes, edges, { clientX, clientY });
+  if (savedBlocks && !isPackage) {
+    if (savedBlocks.settings.asBlock) {
+      const position = editor.value.project({
+        x: clientX - 360,
+        y: clientY - 18,
+      });
+      editor.value.addNodes([
+        {
+          position,
+          id: nanoid(),
+          data: savedBlocks,
+          type: 'BlockPackage',
+          label: 'block-package',
+        },
+      ]);
+    } else {
+      const { nodes, edges } = savedBlocks.data;
+      /* eslint-disable-next-line */
+      const newElements = copyElements(nodes, edges, { clientX, clientY });
 
-    editor.value.addNodes(newElements.nodes);
-    editor.value.addEdges(newElements.edges);
+      editor.value.addNodes(newElements.nodes);
+      editor.value.addEdges(newElements.edges);
+    }
 
     state.dataChanged = true;
     return;
@@ -1090,19 +1185,21 @@ function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
   const block = parseJSON(dataTransfer.getData('block'), null);
   if (!block) return;
 
+  if (block.id === 'trigger' && isPackage) return;
+
   clearHighlightedElements();
 
+  const isTriggerExists =
+    block.id === 'trigger' &&
+    editor.value.getNodes.value.some((node) => node.label === 'trigger');
+  if (isTriggerExists) return;
+
   const nodeEl = DroppedNode.isNode(target);
   if (nodeEl) {
     DroppedNode.replaceNode(editor.value, { block, target: nodeEl });
     return;
   }
 
-  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 nodeId = nanoid();
   const newNode = {
@@ -1231,6 +1328,8 @@ function duplicateElements({ nodes, edges }) {
 
   editor.value.addNodes(newNodes);
   editor.value.addEdges(newEdges);
+
+  state.dataChanged = true;
 }
 function copySelectedElements(data = {}) {
   const nodes = data.nodes || editor.value.getSelectedNodes.value;
@@ -1269,6 +1368,8 @@ async function pasteCopiedElements(position) {
       editor.value.addNodes(nodes);
       editor.value.addEdges(edges);
 
+      state.dataChanged = true;
+
       return;
     }
   } catch (error) {
@@ -1309,7 +1410,7 @@ async function fetchConnectedTable() {
   connectedTable.value = table;
 }
 function checkWorkflowPermission() {
-  getWorkflowPermissions(workflow.value.drawflow).then((permissions) => {
+  getWorkflowPermissions(editorData.value).then((permissions) => {
     if (permissions.length === 0) return;
 
     permissionState.items = permissions;
@@ -1369,17 +1470,26 @@ onBeforeRouteLeave(() => {
 });
 onMounted(() => {
   if (!workflow.value) {
-    router.replace('/');
+    router.replace(isPackage ? '/packages' : '/');
     return null;
   }
 
+  if (isPackage && workflow.value.isExternal) {
+    router.replace('/packages');
+    return;
+  }
+
   state.showSidebar =
     JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
 
-  const convertedData = convertWorkflowData(workflow.value);
-  updateWorkflow({ drawflow: convertedData.drawflow }).then(() => {
+  if (!isPackage) {
+    const convertedData = convertWorkflowData(workflow.value);
+    updateWorkflow({ drawflow: convertedData.drawflow }).then(() => {
+      state.workflowConverted = true;
+    });
+  } else {
     state.workflowConverted = true;
-  });
+  }
 
   if (route.query.permission || (isTeamWorkflow && !haveEditAccess.value))
     checkWorkflowPermission();

+ 11 - 0
src/newtab/router.js

@@ -1,5 +1,6 @@
 import { createRouter, createWebHashHistory } from 'vue-router';
 import Welcome from './pages/Welcome.vue';
+import Packages from './pages/Packages.vue';
 import Workflows from './pages/Workflows.vue';
 import WorkflowHost from './pages/workflows/Host.vue';
 import WorkflowDetails from './pages/workflows/[id].vue';
@@ -29,6 +30,16 @@ const routes = [
     path: '/welcome',
     component: Welcome,
   },
+  {
+    name: 'packages',
+    path: '/packages',
+    component: Packages,
+  },
+  {
+    name: 'packages-details',
+    path: '/packages/:id',
+    component: WorkflowDetails,
+  },
   {
     name: 'workflows',
     path: '/workflows',

+ 107 - 0
src/stores/package.js

@@ -0,0 +1,107 @@
+import { defineStore } from 'pinia';
+import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
+import { fetchApi } from '@/utils/api';
+
+const defaultPackage = {
+  id: '',
+  name: '',
+  icon: 'mdiPackageVariantClosed',
+  isExtenal: false,
+  content: null,
+  inputs: [],
+  outputs: [],
+  variable: [],
+  settings: {
+    asBlock: false,
+  },
+  data: {
+    edges: [],
+    nodes: [],
+  },
+};
+
+export const usePackageStore = defineStore('packages', {
+  storageMap: {
+    packages: 'savedBlocks',
+  },
+  state: () => ({
+    packages: [],
+    sharedPkgs: [],
+    retrieved: false,
+    sharedRetrieved: false,
+  }),
+  getters: {
+    getById: (state) => (pkgId) => {
+      return state.packages.find((pkg) => pkg.id === pkgId);
+    },
+    isShared: (state) => (pkgId) => {
+      return state.sharedPkgs.some((pkg) => pkg.id === pkgId);
+    },
+  },
+  actions: {
+    async insert(data) {
+      this.packages.push({
+        ...defaultPackage,
+        ...data,
+        createdAt: Date.now(),
+        id: nanoid(),
+      });
+      await this.saveToStorage('packages');
+    },
+    async update({ id, data }) {
+      const index = this.packages.findIndex((pkg) => pkg.id === id);
+      if (index === -1) return null;
+
+      Object.assign(this.packages[index], data);
+      await this.saveToStorage('packages');
+
+      return this.packages[index];
+    },
+    async delete(id) {
+      const index = this.packages.findIndex((pkg) => pkg.id === id);
+      if (index === -1) return null;
+
+      const data = this.packages[index];
+      this.packages.splice(index, 1);
+
+      await this.saveToStorage('packages');
+
+      return data;
+    },
+    deleteShared(id) {
+      const index = this.sharedPkgs.findIndex((item) => item.id === id);
+      if (index !== -1) this.sharedPkgs.splice(index, 1);
+    },
+    insertShared(id) {
+      this.sharedPkgs.push({ id });
+    },
+    async loadData(force = false) {
+      if (this.retrieved && !force) return this.packages;
+
+      const { savedBlocks } = await browser.storage.local.get('savedBlocks');
+
+      this.packages = savedBlocks || [];
+      this.retrieved = true;
+
+      return this.packages;
+    },
+    async loadShared() {
+      try {
+        if (this.sharedRetrieved) return;
+
+        const response = await fetchApi('/me/packages');
+        const result = await response.json();
+
+        if (!response.ok) throw new Error(result.message);
+
+        this.sharedPkgs = result;
+        this.sharedRetrieved = true;
+      } catch (error) {
+        console.error(error.message);
+
+        throw error;
+      }
+    },
+  },
+});

+ 1 - 0
src/stores/user.js

@@ -8,6 +8,7 @@ export const useUserStore = defineStore('user', {
     backupIds: [],
     retrieved: false,
     hostedWorkflows: {},
+    sharedPackages: [],
   }),
   getters: {
     getHostedWorkflows: (state) => Object.values(state.hostedWorkflows),

+ 3 - 0
src/utils/referenceData/mustacheReplacer.js

@@ -129,6 +129,9 @@ export const functions = {
 
     return value.toUpperCase();
   },
+  modulo(value, divisor) {
+    return +value % +divisor;
+  },
 };
 
 export function extractStrFunction(str) {

+ 44 - 1
src/utils/shared.js

@@ -511,9 +511,10 @@ export const tasks = {
     outputs: 2,
     allowedInputs: true,
     maxConnection: 1,
+    refDataKeys: ['repeatFor'],
     data: {
       disableBlock: false,
-      repeatFor: 1,
+      repeatFor: '1',
     },
   },
   'javascript-code': {
@@ -735,6 +736,7 @@ export const tasks = {
     data: {
       disableBlock: false,
       loopId: '',
+      clearLoop: false,
     },
   },
   'blocks-group': {
@@ -1261,6 +1263,40 @@ export const tasks = {
       dataColumn: '',
     },
   },
+  'block-package': {
+    name: 'Block package',
+    description: 'Block package',
+    icon: 'riHtml5Line',
+    editComponent: 'EditPackage',
+    component: 'BlockPackage',
+    category: 'package',
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {},
+  },
+  note: {
+    name: 'Note',
+    description: '',
+    icon: 'riFileEditLine',
+    component: 'BlockNote',
+    category: 'general',
+    disableEdit: true,
+    inputs: 1,
+    outputs: 1,
+    allowedInputs: true,
+    maxConnection: 1,
+    data: {
+      disableBlock: false,
+      note: '',
+      drawing: false,
+      width: 280,
+      height: 168,
+      color: 'white',
+      fontSize: 'regular',
+    },
+  },
   ...customBlocks,
 };
 
@@ -1297,6 +1333,11 @@ export const categories = {
     border: 'border-blue-200 dark:border-blue-300',
     color: 'bg-blue-200 dark:bg-blue-300 fill-blue-200 dark:fill-blue-300',
   },
+  package: {
+    name: 'Packages',
+    border: 'border-cyan-200 dark:border-cyan-300',
+    color: 'bg-cyan-200 dark:bg-cyan-300 fill-cyan-200 dark:fill-cyan-300',
+  },
 };
 
 export const tagColors = {
@@ -1341,11 +1382,13 @@ export const workflowCategories = {
 };
 
 export const excludeOnError = [
+  'note',
   'delay',
   'webhook',
   'trigger',
   'while-loop',
   'conditions',
+  'block-package',
   'element-exists',
 ];
 

+ 85 - 4
yarn.lock

@@ -870,7 +870,7 @@
     "@babel/types" "^7.4.4"
     esutils "^2.0.2"
 
-"@babel/runtime@^7.8.4":
+"@babel/runtime@^7.12.5", "@babel/runtime@^7.14.0", "@babel/runtime@^7.8.4":
   version "7.18.9"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
   integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==
@@ -1618,6 +1618,11 @@
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
   integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
 
+"@types/raf@^3.4.0":
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2"
+  integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw==
+
 "@types/range-parser@*":
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
@@ -2204,6 +2209,11 @@ async@^3.2.3:
   resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
   integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
 
+atob@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
+
 autoprefixer@^10.4.7:
   version "10.4.8"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.8.tgz#92c7a0199e1cfb2ad5d9427bd585a3d75895b9e5"
@@ -2361,6 +2371,11 @@ browserslist@^4.14.5, browserslist@^4.20.2, browserslist@^4.21.3:
     node-releases "^2.0.6"
     update-browserslist-db "^1.0.5"
 
+btoa@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73"
+  integrity sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==
+
 buffer-crc32@^0.2.1, buffer-crc32@^0.2.13:
   version "0.2.13"
   resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
@@ -2420,6 +2435,20 @@ caniuse-lite@^1.0.30001370, caniuse-lite@^1.0.30001373:
   resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz#3dab138e3f5485ba2e74bd13eca7fe1037ce6f57"
   integrity sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==
 
+canvg@^3.0.6:
+  version "3.0.10"
+  resolved "https://registry.yarnpkg.com/canvg/-/canvg-3.0.10.tgz#8e52a2d088b6ffa23ac78970b2a9eebfae0ef4b3"
+  integrity sha512-qwR2FRNO9NlzTeKIPIKpnTY6fqwuYSequ8Ru8c0YkYU7U0oW+hLUvWadLvAu1Rl72OMNiFhoLu4f8eUjQ7l/+Q==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+    "@types/raf" "^3.4.0"
+    core-js "^3.8.3"
+    raf "^3.4.1"
+    regenerator-runtime "^0.13.7"
+    rgbcolor "^1.0.1"
+    stackblur-canvas "^2.0.0"
+    svg-pathdata "^6.0.3"
+
 chalk@^2.0.0, chalk@^2.0.1:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -2679,7 +2708,7 @@ core-js-compat@^3.21.0, core-js-compat@^3.22.1:
     browserslist "^4.21.3"
     semver "7.0.0"
 
-core-js@^3.25.0:
+core-js@^3.25.0, core-js@^3.6.0, core-js@^3.8.3:
   version "3.25.0"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.0.tgz#be71d9e0dd648ffd70c44a7ec2319d039357eceb"
   integrity sha512-CVU1xvJEfJGhyCpBrzzzU1kjCfgsGUxhEvwUV2e/cOedYWHdmluamx+knDnmhqALddMG16fZvIqvs9aijsHHaA==
@@ -3040,6 +3069,11 @@ domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1:
   dependencies:
     domelementtype "^2.2.0"
 
+dompurify@^2.2.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd"
+  integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==
+
 domutils@^2.5.2, domutils@^2.8.0:
   version "2.8.0"
   resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
@@ -3608,6 +3642,11 @@ faye-websocket@^0.11.3:
   dependencies:
     websocket-driver ">=0.5.1"
 
+fflate@^0.4.8:
+  version "0.4.8"
+  resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
+  integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
+
 file-entry-cache@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
@@ -3991,7 +4030,7 @@ html-webpack-plugin@^5.5.0:
     pretty-error "^4.0.0"
     tapable "^2.0.0"
 
-html2canvas@^1.4.1:
+html2canvas@^1.0.0-rc.5, html2canvas@^1.4.1:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543"
   integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==
@@ -4458,6 +4497,21 @@ jsonpath@^1.1.1:
     static-eval "2.0.2"
     underscore "1.12.1"
 
+jspdf@^2.5.1:
+  version "2.5.1"
+  resolved "https://registry.yarnpkg.com/jspdf/-/jspdf-2.5.1.tgz#00c85250abf5447a05f3b32ab9935ab4a56592cc"
+  integrity sha512-hXObxz7ZqoyhxET78+XR34Xu2qFGrJJ2I2bE5w4SM8eFaFEkW2xcGRVUss360fYelwRSid/jT078kbNvmoW0QA==
+  dependencies:
+    "@babel/runtime" "^7.14.0"
+    atob "^2.1.2"
+    btoa "^1.2.1"
+    fflate "^0.4.8"
+  optionalDependencies:
+    canvg "^3.0.6"
+    core-js "^3.6.0"
+    dompurify "^2.2.0"
+    html2canvas "^1.0.0-rc.5"
+
 kind-of@^6.0.2:
   version "6.0.3"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
@@ -5179,6 +5233,11 @@ path-type@^4.0.0:
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+performance-now@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
+  integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==
+
 picocolors@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
@@ -5489,6 +5548,13 @@ quick-lru@^5.1.1:
   resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
   integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
 
+raf@^3.4.1:
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
+  integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==
+  dependencies:
+    performance-now "^2.1.0"
+
 randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@@ -5573,7 +5639,7 @@ regenerate@^1.4.2:
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
   integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
 
-regenerator-runtime@^0.13.4:
+regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
   version "0.13.9"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
   integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
@@ -5698,6 +5764,11 @@ rfdc@^1.3.0:
   resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
   integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
 
+rgbcolor@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/rgbcolor/-/rgbcolor-1.0.1.tgz#d6505ecdb304a6595da26fa4b43307306775945d"
+  integrity sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==
+
 rimraf@^2.6.3:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
@@ -6010,6 +6081,11 @@ spdy@^4.0.2:
     select-hose "^2.0.0"
     spdy-transport "^3.0.0"
 
+stackblur-canvas@^2.0.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/stackblur-canvas/-/stackblur-canvas-2.5.0.tgz#aa87bbed1560fdcd3138fff344fc6a1c413ebac4"
+  integrity sha512-EeNzTVfj+1In7aSLPKDD03F/ly4RxEuF/EX0YcOG0cKoPXs+SLZxDawQbexQDBzwROs4VKLWTOaZQlZkGBFEIQ==
+
 static-eval@2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42"
@@ -6154,6 +6230,11 @@ supports-preserve-symlinks-flag@^1.0.0:
   resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
   integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
 
+svg-pathdata@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz#80b0e0283b652ccbafb69ad4f8f73e8d3fbf2cac"
+  integrity sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==
+
 tailwindcss@^3.1.6:
   version "3.1.8"
   resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.1.8.tgz#4f8520550d67a835d32f2f4021580f9fddb7b741"