Browse Source

feat: add packages page

Ahmad Kholid 2 years ago
parent
commit
663a7ff55d

+ 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;
 

+ 2 - 1
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',

+ 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>

+ 25 - 5
src/components/newtab/workflow/editor/EditorLocalActions.vue

@@ -6,7 +6,10 @@
   >
     {{ workflow.tag }}
   </span>
-  <ui-card v-if="!isTeam || !canEdit" padding="p-1 pointer-events-auto">
+  <ui-card
+    v-if="!isPackage && (!isTeam || !canEdit)"
+    padding="p-1 pointer-events-auto"
+  >
     <button
       v-tooltip.group="'Workflow note'"
       class="hoverable p-2 rounded-lg"
@@ -16,7 +19,7 @@
     </button>
   </ui-card>
   <ui-card
-    v-if="!isTeam"
+    v-if="!isTeam && !isPackage"
     padding="p-1"
     class="flex items-center pointer-events-auto ml-4"
   >
@@ -98,7 +101,7 @@
       </ui-list>
     </ui-popover>
   </ui-card>
-  <ui-card v-if="canEdit" padding="p-1 ml-4 pointer-events-auto">
+  <ui-card v-if="canEdit && !isPackage" padding="p-1 ml-4 pointer-events-auto">
     <button
       v-for="item in modalActions"
       :key="item.id"
@@ -109,7 +112,10 @@
       <v-remixicon :name="item.icon" />
     </button>
   </ui-card>
-  <ui-card padding="p-1 ml-4 flex items-center pointer-events-auto">
+  <ui-card
+    v-if="!isPackage"
+    padding="p-1 ml-4 flex items-center pointer-events-auto"
+  >
     <button
       v-if="!workflow.isDisabled"
       v-tooltip.group="
@@ -156,6 +162,7 @@
           <span>{{ t('workflow.host.sync.title') }}</span>
         </ui-list-item>
         <ui-list-item
+          v-if="!isPackage"
           class="cursor-pointer"
           @click="updateWorkflow({ isDisabled: !workflow.isDisabled })"
         >
@@ -259,7 +266,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')"
@@ -315,6 +322,7 @@ 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';
@@ -349,6 +357,7 @@ const props = defineProps({
     default: true,
   },
   isTeam: Boolean,
+  isPackage: Boolean,
 });
 const emit = defineEmits(['modal', 'change', 'update', 'permission']);
 
@@ -359,6 +368,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();
@@ -409,6 +419,11 @@ function updateWorkflow(data = {}, changedIndicator = false) {
       teamId,
       id: props.workflow.id,
     });
+  } else if (props.isPackage) {
+    store = packageStore.update({
+      data,
+      id: props.workflow.id,
+    });
   } else {
     store = workflowStore.update({
       data,
@@ -651,6 +666,11 @@ async function saveWorkflow() {
       return edge;
     });
 
+    if (props.isPackage) {
+      updateWorkflow({ data: flow }, false);
+      return;
+    }
+
     const triggerBlock = flow.nodes.find((node) => node.label === 'trigger');
     if (!triggerBlock) {
       toast.error(t('message.noTriggerBlock'));

+ 6 - 2
src/components/newtab/workflow/editor/EditorLocalCtxMenu.vue

@@ -37,6 +37,7 @@ const props = defineProps({
     type: Object,
     default: () => ({}),
   },
+  isPackage: Boolean,
 });
 const emit = defineEmits([
   'copy',
@@ -75,7 +76,7 @@ const menuItems = {
   },
   saveToFolder: {
     id: 'saveToFolder',
-    name: t('workflow.blocksFolder.save'),
+    name: t('packages.set'),
     event: () => {
       emit('saveBlock', ctxData);
     },
@@ -113,8 +114,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 = {

+ 49 - 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,46 @@
           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 justify-end"
           >
             <v-remixicon
+              v-if="!item.isExternal"
               name="riPencilLine"
               size="18"
               class="cursor-pointer"
-              @click="initEditState(item)"
+              @click="$router.push(`/packages/${item.id}`)"
             />
             <v-remixicon
               name="riDeleteBin7Line"
@@ -69,108 +79,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>

+ 2 - 0
src/lib/vRemixicon.js

@@ -258,6 +258,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",

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

@@ -8,6 +8,18 @@
     "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",
+    "categories": {
+      "my": "My Packages",
+      "installed": "Installed Packages",
+    }
+  },
   "scheduledWorkflow": {
     "title": "Scheduled workflows",
     "nextRun": "Next run",

+ 3 - 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();
@@ -213,6 +215,7 @@ browser.runtime.onMessage.addListener(({ type, data }) => {
       workflowStore.loadData(),
       teamWorkflowStore.loadData(),
       hostedWorkflowStore.loadData(),
+      packageStore.loadData(),
     ]);
 
     await loadLocaleMessages(store.settings.locale, 'newtab');

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

@@ -0,0 +1,229 @@
+<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="w-44">
+                  <ui-list-item
+                    v-close-popover
+                    class="cursor-pointer text-red-500 dark:text-red-400"
+                    @click="deletePackage(pkg)"
+                  >
+                    <v-remixicon name="riPencilLine" class="mr-2 -ml-1" />
+                    <span>{{ t('common.delete') }}</span>
+                  </ui-list-item>
+                </ui-list>
+              </ui-popover>
+            </div>
+            <router-link
+              :to="`/packages/${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 class="flex-1">{{ dayjs(pkg.createdAt).fromNow() }}</p>
+              <p v-if="pkg.author">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>

+ 63 - 47
src/newtab/pages/workflows/[id].vue

@@ -70,7 +70,7 @@
             />
           </button>
           <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
-          <ui-tab value="logs" class="flex items-center">
+          <ui-tab v-if="!isPackage" value="logs" class="flex items-center">
             {{ t('common.log', 2) }}
             <span
               v-if="workflowStates.length > 0"
@@ -97,6 +97,7 @@
           :workflow="workflow"
           :is-data-changed="state.dataChanged"
           :is-team="isTeamWorkflow"
+          :is-package="isPackage"
           :can-edit="haveEditAccess"
           @update="onActionUpdated"
           @permission="checkWorkflowPermission"
@@ -114,7 +115,7 @@
           <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 +154,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,6 +177,7 @@
           <editor-local-ctx-menu
             v-if="editor"
             :editor="editor"
+            :is-package="isPackage"
             @group="groupBlocks"
             @ungroup="ungroupBlocks"
             @copy="copySelectedElements"
@@ -222,31 +225,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 +257,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 {
@@ -299,6 +289,7 @@ import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.
 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 EditorAddPackage from '@/components/newtab/workflow/editor/EditorAddPackage.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';
@@ -317,12 +308,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 +330,7 @@ const state = reactive({
 });
 const blockFolderModal = reactive({
   name: '',
+  icon: '',
   nodes: [],
   description: '',
   showList: false,
@@ -473,6 +467,9 @@ const workflow = computed(() => {
   if (isTeamWorkflow) {
     return teamWorkflowStore.getById(teamId, workflowId);
   }
+  if (isPackage) {
+    return packageStore.getById(workflowId);
+  }
 
   return workflowStore.getById(workflowId);
 });
@@ -489,6 +486,11 @@ const workflowColumns = computed(() => {
 
   return workflow.value.table;
 });
+const editorData = computed(() => {
+  if (isPackage) return workflow.value.data;
+
+  return workflow.value.drawflow;
+});
 
 provide('workflow', {
   editState,
@@ -630,15 +632,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 +647,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 +658,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 +776,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 +802,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);
@@ -953,6 +953,16 @@ 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({
@@ -1090,19 +1100,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 +1243,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 +1283,8 @@ async function pasteCopiedElements(position) {
       editor.value.addNodes(nodes);
       editor.value.addEdges(edges);
 
+      state.dataChanged = true;
+
       return;
     }
   } catch (error) {
@@ -1309,7 +1325,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,7 +1385,7 @@ onBeforeRouteLeave(() => {
 });
 onMounted(() => {
   if (!workflow.value) {
-    router.replace('/');
+    router.replace(isPackage ? '/packages' : '/');
     return null;
   }
 

+ 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',

+ 74 - 0
src/stores/package.js

@@ -0,0 +1,74 @@
+import { defineStore } from 'pinia';
+import { nanoid } from 'nanoid';
+import browser from 'webextension-polyfill';
+
+const defaultPackage = {
+  id: '',
+  name: '',
+  icon: '',
+  isExtenal: false,
+  asBlock: false,
+  inputs: [],
+  outputs: [],
+  variable: [],
+  data: {
+    edges: [],
+    nodes: [],
+  },
+};
+
+export const usePackageStore = defineStore('packages', {
+  storageMap: {
+    packages: 'savedBlocks',
+  },
+  state: () => ({
+    packages: [],
+    retrieved: false,
+  }),
+  getters: {
+    getById: (state) => (pkgId) => {
+      return state.packages.find((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;
+    },
+    async loadData() {
+      if (this.retrieved) return this.packages;
+
+      const { savedBlocks } = await browser.storage.local.get('savedBlocks');
+
+      this.packages = savedBlocks || [];
+      this.retrieved = true;
+
+      return this.packages;
+    },
+  },
+});