Kaynağa Gözat

feat: add popup home page

Ahmad Kholid 3 yıl önce
ebeveyn
işleme
be19a27cb9
43 değiştirilmiş dosya ile 1005 ekleme ve 61 silme
  1. 34 26
      .eslintrc.js
  2. 0 1
      .prettierrc
  3. 6 0
      package.json
  4. 1 1
      postcss.config.js
  5. 57 0
      src/assets/css/fonts.css
  6. 20 1
      src/assets/css/tailwind.css
  7. BIN
      src/assets/fonts/inter-v3-latin-600.woff
  8. BIN
      src/assets/fonts/inter-v3-latin-600.woff2
  9. BIN
      src/assets/fonts/inter-v3-latin-700.woff
  10. BIN
      src/assets/fonts/inter-v3-latin-700.woff2
  11. BIN
      src/assets/fonts/inter-v3-latin-regular.woff
  12. BIN
      src/assets/fonts/inter-v3-latin-regular.woff2
  13. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-700.woff
  14. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-700.woff2
  15. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-italic.woff
  16. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-italic.woff2
  17. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-regular.woff
  18. BIN
      src/assets/fonts/jetbrains-mono-v6-latin-regular.woff2
  19. 13 0
      src/components/popup/home/HomeWorkflowCard.vue
  20. 73 0
      src/components/ui/UiButton.vue
  21. 35 0
      src/components/ui/UiCard.vue
  22. 109 0
      src/components/ui/UiDialog.vue
  23. 96 0
      src/components/ui/UiInput.vue
  24. 16 0
      src/components/ui/UiList.vue
  25. 37 0
      src/components/ui/UiListItem.vue
  26. 146 0
      src/components/ui/UiModal.vue
  27. 121 0
      src/components/ui/UiPopover.vue
  28. 39 0
      src/components/ui/UiSpinner.vue
  29. 5 0
      src/directives/VAutofocus.js
  30. 18 0
      src/lib/comps-ui.js
  31. 18 0
      src/lib/tippy.js
  32. 24 0
      src/lib/v-remixicon.js
  33. 2 2
      src/newtab/App.vue
  34. 5 3
      src/popup/App.vue
  35. 1 1
      src/popup/index.html
  36. 5 1
      src/popup/index.js
  37. 22 0
      src/popup/pages/Home.vue
  38. 15 0
      src/popup/router.js
  39. 16 3
      tailwind.config.js
  40. 2 2
      utils/build.js
  41. 12 11
      utils/webserver.js
  42. 11 9
      webpack.config.js
  43. 46 0
      yarn.lock

+ 34 - 26
.eslintrc.js

@@ -4,7 +4,7 @@
 module.exports = {
   root: true,
   parserOptions: {
-    parser: '@babel/eslint-parser'
+    parser: '@babel/eslint-parser',
   },
   env: {
     browser: true,
@@ -12,39 +12,47 @@ module.exports = {
   },
   // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
   // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
-  extends: ['plugin:vue/essential', 'airbnb-base', 'plugin:prettier/recommended'],
-  // required to lint *.vue files
-  plugins: [
-    'vue'
+  extends: [
+    'plugin:vue/vue3-recommended',
+    'airbnb-base',
+    'plugin:prettier/recommended',
   ],
+  // required to lint *.vue files
+  plugins: ['vue'],
   // check if imports actually resolve
   settings: {
     'import/resolver': {
       webpack: {
-        config: './webpack.config.js'
-      }
-    }
+        config: './webpack.config.js',
+      },
+    },
   },
   // add your custom rules here
   rules: {
-    // don't require .vue extension when importing
-    'import/extensions': ['error', 'always', {
-      js: 'never',
-      vue: 'never'
-    }],
-      // disallow reassignment of function parameters
-      // disallow parameter object manipulation except for specific exclusions
-      'no-param-reassign': ['error', {
-      props: true,
-      ignorePropertyModificationsFor: [
-        'state', // for vuex state
-        'acc', // for reduce accumulators
-        'e' // for e.returnvalue
-      ]
-    }],
+    'import/extensions': [
+      'error',
+      'always',
+      {
+        js: 'never',
+      },
+    ],
+    // disallow reassignment of function parameters
+    // disallow parameter object manipulation except for specific exclusions
+    'no-param-reassign': [
+      'error',
+      {
+        props: true,
+        ignorePropertyModificationsFor: [
+          'state', // for vuex state
+          'acc', // for reduce accumulators
+          'e', // for e.returnvalue
+        ],
+      },
+    ],
+    'import/no-extraneous-dependencies': 'off',
     // disallow default export over named export
     'import/prefer-default-export': 'off',
     // allow debugger during development
-    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
-  }
-}
+    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
+  },
+};

+ 0 - 1
.prettierrc

@@ -1,6 +1,5 @@
 {
   "singleQuote": true,
   "trailingComma": "es5",
-  "requirePragma": false,
   "arrowParens": "always"
 }

+ 6 - 0
package.json

@@ -15,7 +15,12 @@
   },
   "dependencies": {
     "@medv/finder": "^2.1.0",
+    "tiny-emitter": "^2.1.0",
+    "tippy.js": "^6.3.1",
+    "v-remixicon": "^0.0.12",
     "vue": "^3.2.11",
+    "vue-router": "^4.0.11",
+    "vuex": "^4.0.2",
     "webext-bridge": "^4.1.1",
     "webextension-polyfill": "^0.8.0"
   },
@@ -33,6 +38,7 @@
     "css-loader": "^5.0.2",
     "eslint": "^7.20.0",
     "eslint-config-airbnb-base": "^14.2.1",
+    "eslint-config-prettier": "^8.3.0",
     "eslint-friendly-formatter": "^4.0.1",
     "eslint-import-resolver-webpack": "^0.13.1",
     "eslint-plugin-flowtype": "^5.2.2",

+ 1 - 1
postcss.config.js

@@ -3,4 +3,4 @@ module.exports = {
     tailwindcss: {},
     autoprefixer: {},
   },
-}
+};

+ 57 - 0
src/assets/css/fonts.css

@@ -0,0 +1,57 @@
+/* inter-regular - latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 400;
+  src: local(''),
+       url('../fonts/inter-v3-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/inter-v3-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* inter-600 - latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 600;
+  src: local(''),
+       url('../fonts/inter-v3-latin-600.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/inter-v3-latin-600.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* inter-700 - latin */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 700;
+  src: local(''),
+       url('../fonts/inter-v3-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/inter-v3-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+
+
+/* fira-code-regular - latin */
+/* jetbrains-mono-regular - latin */
+@font-face {
+  font-family: 'JetBrains Mono';
+  font-style: normal;
+  font-weight: 400;
+  src: local(''),
+       url('../fonts/jetbrains-mono-v6-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/jetbrains-mono-v6-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* jetbrains-mono-700 - latin */
+@font-face {
+  font-family: 'JetBrains Mono';
+  font-style: normal;
+  font-weight: 700;
+  src: local(''),
+       url('../fonts/jetbrains-mono-v6-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/jetbrains-mono-v6-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}
+/* jetbrains-mono-italic - latin */
+@font-face {
+  font-family: 'JetBrains Mono';
+  font-style: italic;
+  font-weight: 400;
+  src: local(''),
+       url('../fonts/jetbrains-mono-v6-latin-italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
+       url('../fonts/jetbrains-mono-v6-latin-italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+}

+ 20 - 1
src/assets/css/tailwind.css

@@ -1,3 +1,22 @@
 @tailwind base;
 @tailwind components;
-@tailwind utilities;
+@tailwind utilities;
+
+body {
+	font-family: 'Inter', sans-serif;
+}
+
+input:focus,
+button:focus,
+textarea:focus,
+select:focus {
+	outline: none;
+	@apply ring-2 ring-accent dark:ring-gray-200;
+}
+
+.hoverable {
+  @apply hover:bg-gray-800 hover:bg-opacity-5 dark:hover:bg-gray-200 dark:hover:bg-opacity-5;
+}
+.bg-input {
+  @apply bg-black bg-opacity-5 hover:bg-opacity-10 dark:bg-gray-200 dark:bg-opacity-5 dark:hover:bg-opacity-10 focus:ring-2;
+}

BIN
src/assets/fonts/inter-v3-latin-600.woff


BIN
src/assets/fonts/inter-v3-latin-600.woff2


BIN
src/assets/fonts/inter-v3-latin-700.woff


BIN
src/assets/fonts/inter-v3-latin-700.woff2


BIN
src/assets/fonts/inter-v3-latin-regular.woff


BIN
src/assets/fonts/inter-v3-latin-regular.woff2


BIN
src/assets/fonts/jetbrains-mono-v6-latin-700.woff


BIN
src/assets/fonts/jetbrains-mono-v6-latin-700.woff2


BIN
src/assets/fonts/jetbrains-mono-v6-latin-italic.woff


BIN
src/assets/fonts/jetbrains-mono-v6-latin-italic.woff2


BIN
src/assets/fonts/jetbrains-mono-v6-latin-regular.woff


BIN
src/assets/fonts/jetbrains-mono-v6-latin-regular.woff2


+ 13 - 0
src/components/popup/home/HomeWorkflowCard.vue

@@ -0,0 +1,13 @@
+<template>
+  <ui-card
+    class="w-full flex items-center space-x-2 hover:ring-2 hover:ring-gray-900"
+  >
+    <div class="flex-1">
+      <p class="leading-tight">halo {{ 1 }}</p>
+      <p class="leading-none text-gray-500">3 days ago</p>
+    </div>
+    <ui-button icon>
+      <v-remixicon name="riPlayLine" />
+    </ui-button>
+  </ui-card>
+</template>

+ 73 - 0
src/components/ui/UiButton.vue

@@ -0,0 +1,73 @@
+<template>
+  <component
+    :is="tag"
+    role="button"
+    class="ui-button h-10 relative transition"
+    :class="[
+      color ? color : variants[variant],
+      icon ? 'p-2' : 'py-2 px-4',
+      circle ? 'rounded-full' : 'rounded-lg',
+      { 'opacity-70': disabled, 'pointer-events-none': loading || disabled },
+    ]"
+    v-bind="{ disabled: loading || disabled, ...$attrs }"
+  >
+    <span
+      class="flex justify-center h-full items-center"
+      :class="{ 'opacity-25': loading }"
+    >
+      <slot></slot>
+    </span>
+    <div v-if="loading" class="button-loading">
+      <ui-spinner
+        :color="variant === 'default' ? 'text-primary' : 'text-white'"
+      ></ui-spinner>
+    </div>
+  </component>
+</template>
+<script>
+import UiSpinner from './UiSpinner.vue';
+
+export default {
+  components: { UiSpinner },
+  props: {
+    icon: Boolean,
+    disabled: Boolean,
+    loading: Boolean,
+    circle: Boolean,
+    color: {
+      type: String,
+      default: '',
+    },
+    tag: {
+      type: String,
+      default: 'button',
+    },
+    variant: {
+      type: String,
+      default: 'default',
+    },
+  },
+  setup() {
+    const variants = {
+      default: 'bg-input',
+      accent: 'bg-accent hover:bg-gray-700 text-white',
+      primary:
+        'bg-primary text-white dark:bg-secondary dark:hover:bg-primary hover:bg-secondary',
+      danger:
+        'bg-red-500 text-white dark:bg-red-600 dark:hover:bg-red-500 hover:bg-red-600',
+    };
+
+    return {
+      variants,
+    };
+  },
+};
+</script>
+<style>
+.button-loading {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+</style>

+ 35 - 0
src/components/ui/UiCard.vue

@@ -0,0 +1,35 @@
+<template>
+  <component
+    :is="tag"
+    v-bind="$attrs"
+    class="
+      bg-white
+      dark:bg-gray-800
+      transform
+      rounded-lg
+      transition-transform
+      ui-card
+    "
+    :class="[padding, { 'hover:shadow-xl hover:-translate-y-1': hover }]"
+  >
+    <slot></slot>
+  </component>
+</template>
+<script>
+export default {
+  props: {
+    hover: {
+      type: Boolean,
+      default: false,
+    },
+    padding: {
+      type: String,
+      default: 'p-4',
+    },
+    tag: {
+      type: String,
+      default: 'div',
+    },
+  },
+};
+</script>

+ 109 - 0
src/components/ui/UiDialog.vue

@@ -0,0 +1,109 @@
+<template>
+  <ui-modal :model-value="state.show" content-class="max-w-sm" persist>
+    <template #header>
+      <h3 class="font-semibold text-lg">{{ state.options.title }}</h3>
+    </template>
+    <p class="text-gray-600 dark:text-gray-200 leading-tight">
+      {{ state.options.body }}
+    </p>
+    <ui-input
+      v-if="state.type === 'prompt'"
+      v-model="state.input"
+      autofocus
+      :placeholder="state.options.placeholder"
+      :label="state.options.label"
+      class="w-full mt-4"
+    ></ui-input>
+    <div class="mt-8 flex space-x-2">
+      <ui-button class="w-6/12" @click="fireCallback('onCancel')">
+        {{ state.options.cancelText }}
+      </ui-button>
+      <ui-button
+        class="w-6/12"
+        :variant="state.options.okVariant"
+        @click="fireCallback('onConfirm')"
+      >
+        {{ state.options.okText }}
+      </ui-button>
+    </div>
+  </ui-modal>
+</template>
+<script>
+import { reactive, watch } from 'vue';
+import emitter from 'tiny-emitter/instance';
+
+const defaultOptions = {
+  html: false,
+  body: '',
+  title: '',
+  placeholder: '',
+  label: '',
+  okText: 'Confirm',
+  okVariant: 'primary',
+  cancelText: 'Cancel',
+  onConfirm: null,
+  onCancel: null,
+};
+
+export default {
+  setup() {
+    const state = reactive({
+      show: false,
+      type: '',
+      input: '',
+      options: defaultOptions,
+    });
+
+    emitter.on('show-dialog', (type, options) => {
+      state.type = type;
+      state.options = {
+        ...defaultOptions,
+        ...options,
+      };
+
+      state.show = true;
+    });
+
+    function fireCallback(type) {
+      const callback = state.options[type];
+      const param = state.type === 'prompt' ? state.input : true;
+      let hide = true;
+
+      if (callback) {
+        const cbReturn = callback(param);
+
+        if (typeof cbReturn === 'boolean') hide = cbReturn;
+      }
+
+      if (hide) {
+        state.options = defaultOptions;
+        state.show = false;
+        state.input = '';
+      }
+    }
+    function keyupHandler({ code }) {
+      if (code === 'Enter') {
+        fireCallback('onConfirm');
+      } else if (code === 'Escape') {
+        fireCallback('onCancel');
+      }
+    }
+
+    watch(
+      () => state.show,
+      (value) => {
+        if (value) {
+          window.addEventListener('keyup', keyupHandler);
+        } else {
+          window.removeEventListener('keyup', keyupHandler);
+        }
+      }
+    );
+
+    return {
+      state,
+      fireCallback,
+    };
+  },
+};
+</script>

+ 96 - 0
src/components/ui/UiInput.vue

@@ -0,0 +1,96 @@
+<template>
+  <div class="inline-block input-ui">
+    <label class="relative">
+      <span
+        v-if="label"
+        class="text-sm dark:text-gray-200 text-gray-600 mb-1 ml-1"
+      >
+        {{ label }}
+      </span>
+      <div class="flex items-center">
+        <slot name="prepend">
+          <v-remixicon
+            v-if="prependIcon"
+            class="ml-2 dark:text-gray-200 text-gray-600 absolute left-0"
+            :name="prependIcon"
+          ></v-remixicon>
+        </slot>
+        <input
+          v-autofocus="autofocus"
+          v-bind="{
+            readonly: disabled || readonly || null,
+            placeholder,
+            type,
+            autofocus,
+          }"
+          class="py-2 px-4 rounded-lg w-full bg-input bg-transparent transition"
+          :class="{
+            'opacity-75 pointer-events-none': disabled,
+            'pl-10': prependIcon || $slots.prepend,
+          }"
+          :value="modelValue"
+          @keydown="$emit('keydown', $event)"
+          @input="emitValue"
+        />
+      </div>
+    </label>
+  </div>
+</template>
+<script>
+export default {
+  props: {
+    modelModifiers: {
+      default: () => ({}),
+    },
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+    readonly: {
+      type: Boolean,
+      default: false,
+    },
+    autofocus: {
+      type: Boolean,
+      default: false,
+    },
+    modelValue: {
+      type: String,
+      default: '',
+    },
+    prependIcon: {
+      type: String,
+      default: '',
+    },
+    label: {
+      type: String,
+      default: '',
+    },
+    type: {
+      type: String,
+      default: 'text',
+    },
+    placeholder: {
+      type: String,
+      default: '',
+    },
+  },
+  emits: ['update:modelValue', 'change', 'keydown'],
+  setup(props, { emit }) {
+    function emitValue(event) {
+      let { value } = event.target;
+
+      if (props.modelModifiers.lowercase) {
+        value = value.toLocaleLowerCase();
+      }
+
+      emit('update:modelValue', value);
+      emit('change', value);
+    }
+
+    return {
+      emitValue,
+    };
+  },
+};
+</script>

+ 16 - 0
src/components/ui/UiList.vue

@@ -0,0 +1,16 @@
+<template>
+  <div
+    role="listbox"
+    class="ui-list"
+    :class="{ 'pointer-events-none': disabled }"
+  >
+    <slot></slot>
+  </div>
+</template>
+<script>
+export default {
+  props: {
+    disabled: Boolean,
+  },
+};
+</script>

+ 37 - 0
src/components/ui/UiListItem.vue

@@ -0,0 +1,37 @@
+<template>
+  <component
+    :is="tag"
+    class="
+      ui-list-item
+      rounded-lg
+      flex
+      items-center
+      transition
+      w-full
+      focus:outline-none
+    "
+    role="listitem"
+    :class="[
+      active
+        ? 'bg-primary bg-opacity-10 text-primary dark:bg-secondary dark:bg-opacity-10 dark:text-secondary'
+        : 'hoverable',
+      small ? 'p-2' : 'py-2 px-4',
+      { 'pointer-events-none bg-opacity-75': disabled },
+    ]"
+  >
+    <slot></slot>
+  </component>
+</template>
+<script>
+export default {
+  props: {
+    active: Boolean,
+    disabled: Boolean,
+    small: Boolean,
+    tag: {
+      type: String,
+      default: 'div',
+    },
+  },
+};
+</script>

+ 146 - 0
src/components/ui/UiModal.vue

@@ -0,0 +1,146 @@
+<template>
+  <div class="modal-ui">
+    <div v-if="$slots.activator" class="modal-ui__activator">
+      <slot name="activator" v-bind="{ open: () => (show = true) }"></slot>
+    </div>
+    <teleport :to="teleportTo" :disabled="disabledTeleport">
+      <transition name="modal" mode="out-in">
+        <div
+          v-if="show"
+          class="
+            bg-black
+            p-5
+            overflow-y-auto
+            bg-opacity-20
+            modal-ui__content-container
+            z-50
+            flex
+            justify-center
+            items-end
+            md:items-center
+          "
+          :style="{ 'backdrop-filter': blur && 'blur(2px)' }"
+          @click.self="closeModal"
+        >
+          <slot v-if="customContent"></slot>
+          <ui-card
+            v-else
+            class="modal-ui__content shadow-lg w-full"
+            :class="[contentClass]"
+          >
+            <div class="mb-4">
+              <div class="flex items-center justify-between">
+                <span class="content-header">
+                  <slot name="header"></slot>
+                </span>
+                <v-remixicon
+                  v-show="!persist"
+                  class="text-gray-600 cursor-pointer"
+                  name="riCloseLine"
+                  size="20"
+                  @click="closeModal"
+                ></v-remixicon>
+              </div>
+            </div>
+            <slot></slot>
+          </ui-card>
+        </div>
+      </transition>
+    </teleport>
+  </div>
+</template>
+<script>
+import { ref, watch } from 'vue';
+
+export default {
+  props: {
+    modelValue: {
+      type: Boolean,
+      default: false,
+    },
+    teleportTo: {
+      type: String,
+      default: 'body',
+    },
+    contentClass: {
+      type: String,
+      default: 'max-w-lg',
+    },
+    customContent: Boolean,
+    persist: Boolean,
+    blur: Boolean,
+    disabledTeleport: Boolean,
+  },
+  emits: ['close', 'update:modelValue'],
+  setup(props, { emit }) {
+    const show = ref(false);
+    const modalContent = ref(null);
+
+    function toggleBodyOverflow(value) {
+      document.body.classList.toggle('overflow-hidden', value);
+    }
+    function closeModal() {
+      if (props.persist) return;
+
+      show.value = false;
+      emit('close', false);
+      emit('update:modelValue', false);
+
+      toggleBodyOverflow(false);
+    }
+    function keyupHandler({ code }) {
+      if (code === 'Escape') closeModal();
+    }
+
+    watch(
+      () => props.modelValue,
+      (value) => {
+        show.value = value;
+        toggleBodyOverflow(value);
+      },
+      { immediate: true }
+    );
+
+    watch(show, (value) => {
+      if (value) window.addEventListener('keyup', keyupHandler);
+      else window.removeEventListener('keyup', keyupHandler);
+    });
+
+    return {
+      show,
+      closeModal,
+      modalContent,
+    };
+  },
+};
+</script>
+<style>
+.modal-enter-active,
+.modal-leave-active {
+  transition: opacity 0.3s ease;
+}
+
+.modal-enter-active .modal-ui__content,
+.modal-leave-active .modal-ui__content {
+  transition: transform 0.3s ease;
+  transform: translateY(0px);
+}
+
+.modal-enter-from,
+.modal-leave-to {
+  opacity: 0;
+}
+
+.modal-enter-from .modal-ui__content,
+.modal-leave-to .modal-ui__content {
+  transform: translateY(30px);
+}
+
+.modal-ui__content-container {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+}
+</style>

+ 121 - 0
src/components/ui/UiPopover.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="ui-popover inline-block" :class="{ hidden: to }">
+    <div ref="targetEl" class="ui-popover__trigger h-full inline-block">
+      <slot name="trigger" v-bind="{ isShow }"></slot>
+    </div>
+    <div
+      ref="content"
+      class="
+        ui-popover__content
+        bg-white
+        dark:bg-gray-800
+        rounded-lg
+        shadow-xl
+        border
+      "
+      :class="[padding]"
+    >
+      <slot v-bind="{ isShow }"></slot>
+    </div>
+  </div>
+</template>
+<script>
+import { ref, onMounted, watch, shallowRef, onUnmounted } from 'vue';
+import createTippy from '@/lib/tippy';
+
+export default {
+  props: {
+    placement: {
+      type: String,
+      default: 'bottom',
+    },
+    trigger: {
+      type: String,
+      default: 'click',
+    },
+    padding: {
+      type: String,
+      default: 'p-4',
+    },
+    to: {
+      type: [String, Object, HTMLElement],
+      default: '',
+    },
+    disabled: {
+      type: Boolean,
+      default: false,
+    },
+    modelValue: {
+      type: Boolean,
+      default: false,
+    },
+  },
+  emits: ['show', 'trigger', 'close'],
+  setup(props, { emit }) {
+    const targetEl = ref(null);
+    const content = ref(null);
+    const isShow = ref(false);
+    const instance = shallowRef(null);
+
+    watch(
+      () => props.disabled,
+      (value) => {
+        if (value) {
+          instance.value.enable();
+        } else {
+          instance.value.hide();
+          instance.value.disable();
+        }
+      }
+    );
+    watch(
+      () => props.modelValue,
+      (value) => {
+        if (value === isShow.value) return;
+
+        isShow.value = value;
+
+        /* eslint-disable-next-line */
+        value ? instance.value.show() : instance.value.hide();
+      }
+    );
+
+    onMounted(() => {
+      /* eslint-disable-next-line */
+      const target = props.to
+        ? typeof to === 'string'
+          ? document.querySelector(props.to)
+          : props.to
+        : targetEl.value;
+
+      instance.value = createTippy(target, {
+        role: 'popover',
+        theme: null,
+        content: content.value,
+        placement: props.placement,
+        trigger: props.trigger,
+        interactive: true,
+        appendTo: () => document.body,
+        onShow: (event) => {
+          emit('show', event);
+          isShow.value = true;
+        },
+        onHide: () => {
+          emit('close');
+          isShow.value = false;
+        },
+        onTrigger: () => emit('trigger'),
+      });
+    });
+    onUnmounted(() => {
+      instance.value.destroy();
+    });
+
+    return {
+      isShow,
+      content,
+      targetEl,
+    };
+  },
+};
+</script>

+ 39 - 0
src/components/ui/UiSpinner.vue

@@ -0,0 +1,39 @@
+<template>
+  <svg
+    class="animate-spin inline-block"
+    :class="[color]"
+    xmlns="http://www.w3.org/2000/svg"
+    fill="none"
+    :width="size"
+    :height="size"
+    viewBox="0 0 24 24"
+  >
+    <circle
+      class="opacity-25"
+      cx="12"
+      cy="12"
+      r="10"
+      stroke="currentColor"
+      stroke-width="4"
+    ></circle>
+    <path
+      class="opacity-75"
+      fill="currentColor"
+      d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+    ></path>
+  </svg>
+</template>
+<script>
+export default {
+  props: {
+    size: {
+      type: [String, Number],
+      default: '24',
+    },
+    color: {
+      type: String,
+      default: 'text-primary',
+    },
+  },
+};
+</script>

+ 5 - 0
src/directives/VAutofocus.js

@@ -0,0 +1,5 @@
+export default {
+  mounted(el, { value = true }) {
+    if (value) el.focus();
+  },
+};

+ 18 - 0
src/lib/comps-ui.js

@@ -0,0 +1,18 @@
+import VAutofocus from '../directives/VAutofocus';
+
+const uiComponents = require.context('../components/ui', false, /\.vue$/);
+
+function componentsExtractor(app, components) {
+  components.keys().forEach((key) => {
+    const componentName = key.replace(/(.\/)|\.vue$/g, '');
+    const component = components(key)?.default ?? {};
+
+    app.component(componentName, component);
+  });
+}
+
+export default function (app) {
+  app.directive('autofocus', VAutofocus);
+
+  componentsExtractor(app, uiComponents);
+}

+ 18 - 0
src/lib/tippy.js

@@ -0,0 +1,18 @@
+import tippy from 'tippy.js';
+import 'tippy.js/animations/shift-toward-subtle.css';
+
+export const defaultOptions = {
+  animation: 'shift-toward-subtle',
+  theme: 'my-theme',
+};
+
+export default function (el, options = {}) {
+  el?.setAttribute('vtooltip', '');
+
+  const instance = tippy(el, {
+    ...defaultOptions,
+    ...options,
+  });
+
+  return instance;
+}

+ 24 - 0
src/lib/v-remixicon.js

@@ -0,0 +1,24 @@
+import vRemixicon from 'v-remixicon';
+import {
+  riHome5Line,
+  riPlayLine,
+  riPauseLine,
+  riSearch2Line,
+  riMoreLine,
+  riDeleteBin7Line,
+  riPencilLine,
+  riExternalLinkLine,
+} from 'v-remixicon/icons';
+
+vRemixicon.add({
+  riHome5Line,
+  riPlayLine,
+  riPauseLine,
+  riSearch2Line,
+  riMoreLine,
+  riDeleteBin7Line,
+  riPencilLine,
+  riExternalLinkLine,
+});
+
+export default vRemixicon;

+ 2 - 2
src/newtab/App.vue

@@ -1,3 +1,3 @@
 <template>
-	<p>hola?</p>
-</template>
+  <p>hola?</p>
+</template>

+ 5 - 3
src/popup/App.vue

@@ -1,9 +1,11 @@
 <template>
-	<p>holas</p>
+  <router-view />
 </template>
 <style>
 body {
-	height: 500px;
-	width: 300px;
+  height: 500px;
+  width: 330px;
+  font-size: 16px;
+  @apply bg-gray-100 dark:bg-gray-900;
 }
 </style>

+ 1 - 1
src/popup/index.html

@@ -6,6 +6,6 @@
   </head>
 
   <body>
-    <div id="app"></div>
+    <div id="app" class="scroll"></div>
   </body>
 </html>

+ 5 - 1
src/popup/index.js

@@ -1,7 +1,11 @@
 import { createApp } from 'vue';
 import App from './App.vue';
+import router from './router';
+import compsUi from '../lib/comps-ui';
+import vRemixicon from '../lib/v-remixicon';
 import '../assets/css/tailwind.css';
+import '../assets/css/fonts.css';
 
-createApp(App).mount('#app');
+createApp(App).use(router).use(compsUi).use(vRemixicon).mount('#app');
 
 if (module.hot) module.hot.accept();

+ 22 - 0
src/popup/pages/Home.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="bg-accent rounded-b-2xl absolute top-0 left-0 h-32 w-full"></div>
+  <div
+    class="flex dark placeholder-black text-white px-5 pt-8 mb-6 items-center"
+  >
+    <ui-input
+      autofocus
+      prepend-icon="riSearch2Line"
+      class="flex-1 search-input"
+      placeholder="Search..."
+    ></ui-input>
+    <ui-button icon title="dashboard" class="ml-3">
+      <v-remixicon name="riHome5Line" />
+    </ui-button>
+  </div>
+  <div class="px-5 pb-5 space-y-2">
+    <home-workflow-card v-for="i in 10" :key="i" />
+  </div>
+</template>
+<script setup>
+import HomeWorkflowCard from '@/components/popup/home/HomeWorkflowCard.vue';
+</script>

+ 15 - 0
src/popup/router.js

@@ -0,0 +1,15 @@
+import { createRouter, createWebHashHistory } from 'vue-router';
+import Home from './pages/Home.vue';
+
+const routes = [
+  {
+    path: '/',
+    name: 'home',
+    component: Home,
+  },
+];
+
+export default createRouter({
+  routes,
+  history: createWebHashHistory(),
+});

+ 16 - 3
tailwind.config.js

@@ -1,12 +1,25 @@
+const colors = require('tailwindcss/colors');
+
 module.exports = {
   mode: 'jit',
   purge: ['./src/**/*.{js,jsx,ts,tsx,vue}'],
-  darkMode: false, // or 'media' or 'class'
+  darkMode: 'class', // or 'media' or 'class'
   theme: {
-    extend: {},
+    extend: {
+      colors: {
+        primary: colors.blue['500'],
+        secondary: colors.blue['400'],
+        accent: colors.gray['900'],
+        gray: colors.gray,
+      },
+      fontFamily: {
+        sans: ['Poppins', 'sans-serif'],
+        mono: ['JetBrains Mono', 'monospace'],
+      },
+    },
   },
   variants: {
     extend: {},
   },
   plugins: [],
-}
+};

+ 2 - 2
utils/build.js

@@ -3,8 +3,8 @@ process.env.BABEL_ENV = 'production';
 process.env.NODE_ENV = 'production';
 process.env.ASSET_PATH = '/';
 
-var webpack = require('webpack'),
-  config = require('../webpack.config');
+const webpack = require('webpack');
+const config = require('../webpack.config');
 
 delete config.chromeExtensionBoilerplate;
 

+ 12 - 11
utils/webserver.js

@@ -3,19 +3,20 @@ process.env.BABEL_ENV = 'development';
 process.env.NODE_ENV = 'development';
 process.env.ASSET_PATH = '/';
 
-var WebpackDevServer = require('webpack-dev-server'),
-  webpack = require('webpack'),
-  config = require('../webpack.config'),
-  env = require('./env'),
-  path = require('path');
+const WebpackDevServer = require('webpack-dev-server');
+const webpack = require('webpack');
+const path = require('path');
+const config = require('../webpack.config');
+const env = require('./env');
 
-var options = config.chromeExtensionBoilerplate || {};
-var excludeEntriesToHotReload = options.notHotReload || [];
+const options = config.chromeExtensionBoilerplate || {};
+const excludeEntriesToHotReload = options.notHotReload || [];
 
-for (var entryName in config.entry) {
+/* eslint-disable-next-line */
+for (const entryName in config.entry) {
   if (excludeEntriesToHotReload.indexOf(entryName) === -1) {
     config.entry[entryName] = [
-      'webpack-dev-server/client?http://localhost:' + env.PORT,
+      `webpack-dev-server/client?http://localhost:${env.PORT}`,
       'webpack/hot/dev-server',
     ].concat(config.entry[entryName]);
   }
@@ -27,9 +28,9 @@ config.plugins = [new webpack.HotModuleReplacementPlugin()].concat(
 
 delete config.chromeExtensionBoilerplate;
 
-var compiler = webpack(config);
+const compiler = webpack(config);
 
-var server = new WebpackDevServer(compiler, {
+const server = new WebpackDevServer(compiler, {
   https: false,
   hot: true,
   injectClient: false,

+ 11 - 9
webpack.config.js

@@ -10,10 +10,12 @@ const env = require('./utils/env');
 
 const ASSET_PATH = process.env.ASSET_PATH || '/';
 
-const alias = {};
+const alias = {
+  '@': path.resolve(__dirname, 'src/'),
+};
 
 // load the secrets
-const secretsPath = path.join(__dirname, 'secrets.' + env.NODE_ENV + '.js');
+const secretsPath = path.join(__dirname, `secrets.${env.NODE_ENV}.js`);
 
 const fileExtensions = [
   'jpg',
@@ -29,7 +31,7 @@ const fileExtensions = [
 ];
 
 if (fileSystem.existsSync(secretsPath)) {
-  alias['secrets'] = secretsPath;
+  alias.secrets = secretsPath;
 }
 
 const options = {
@@ -52,7 +54,7 @@ const options = {
     rules: [
       {
         test: /\.vue$/,
-        loader: 'vue-loader'
+        loader: 'vue-loader',
       },
       {
         // look for .css or .scss files
@@ -67,11 +69,11 @@ const options = {
           },
           {
             loader: 'postcss-loader',
-          },          
+          },
         ],
       },
       {
-        test: new RegExp('.(' + fileExtensions.join('|') + ')$'),
+        test: new RegExp(`.(${fileExtensions.join('|')})$`),
         loader: 'file-loader',
         options: {
           name: '[name].[ext]',
@@ -93,9 +95,9 @@ const options = {
     ],
   },
   resolve: {
-    alias: alias,
+    alias,
     extensions: fileExtensions
-      .map((extension) => '.' + extension)
+      .map((extension) => `.${extension}`)
       .concat(['.js', '.vue', '.css']),
   },
   plugins: [
@@ -114,7 +116,7 @@ const options = {
           from: 'src/manifest.json',
           to: path.join(__dirname, 'build'),
           force: true,
-          transform: function (content, path) {
+          transform(content) {
             // generates the manifest file using the package.json informations
             return Buffer.from(
               JSON.stringify({

+ 46 - 0
yarn.lock

@@ -944,6 +944,11 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@popperjs/core@^2.8.3":
+  version "2.10.1"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
+  integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
+
 "@types/eslint-scope@^3.7.0":
   version "3.7.1"
   resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e"
@@ -1102,6 +1107,11 @@
     "@vue/compiler-dom" "3.2.11"
     "@vue/shared" "3.2.11"
 
+"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.0.0-beta.14":
+  version "6.0.0-beta.15"
+  resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.0-beta.15.tgz#ad7cb384e062f165bcf9c83732125bffbc2ad83d"
+  integrity sha512-quBx4Jjpexo6KDiNUGFr/zF/2A4srKM9S9v2uHgMXSU//hjgq1eGzqkIFql8T9gfX5ZaVOUzYBP3jIdIR3PKIA==
+
 "@vue/reactivity@3.2.11":
   version "3.2.11"
   resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.11.tgz#ec04d33acaf2b92cca2960535bec81b26cc5772b"
@@ -2562,6 +2572,11 @@ eslint-config-airbnb-base@^14.2.1:
     object.assign "^4.1.2"
     object.entries "^1.1.2"
 
+eslint-config-prettier@^8.3.0:
+  version "8.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.3.0.tgz#f7471b20b6fe8a9a9254cc684454202886a2dd7a"
+  integrity sha512-BgZuLUSeKzvlL/VUjx/Yb787VQ26RU3gGjA3iiFvdsp/2bMfVIWUVP7tjxtjS0e+HP409cPlPvNkQloz8C91ew==
+
 eslint-friendly-formatter@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/eslint-friendly-formatter/-/eslint-friendly-formatter-4.0.1.tgz#27d504dc837f7caddbf201b2e84a4ee730ba3efa"
@@ -6067,11 +6082,23 @@ thunky@^1.0.2:
   resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
   integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
 
+tiny-emitter@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
+  integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
+
 tiny-uid@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/tiny-uid/-/tiny-uid-1.1.1.tgz#e7b547a638bc336704d7cd6f7eb6ec52f352c6dc"
   integrity sha512-YRtEXpxokCLMMR46Ml/gen6jDFjZpyo9BMlEGmEpElA+zbCKtoTTikvvDTKR4Gro6tNij3ZA+60i9UiCYKKVyw==
 
+tippy.js@^6.3.1:
+  version "6.3.1"
+  resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.3.1.tgz#3788a007be7015eee0fd589a66b98fb3f8f10181"
+  integrity sha512-JnFncCq+rF1dTURupoJ4yPie5Cof978inW6/4S6kmWV7LL9YOSEVMifED3KdrVPEG+Z/TFH2CDNJcQEfaeuQww==
+  dependencies:
+    "@popperjs/core" "^2.8.3"
+
 tmp@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
@@ -6275,6 +6302,11 @@ uuid@^3.3.2, uuid@^3.4.0:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
+v-remixicon@^0.0.12:
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/v-remixicon/-/v-remixicon-0.0.12.tgz#3483cdb6d31bada66ad71445b05af226af6c0482"
+  integrity sha512-SWC/qq0gF7AHQA5HHtAFhwkv2R2QVSRy+OEt2nkXdN6D6ZTTwf3ftzF5IouF6RIyslKKFoyicwbqQDGY1iG3+g==
+
 v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@@ -6315,6 +6347,13 @@ vue-loader@^16.5.0:
     hash-sum "^2.0.0"
     loader-utils "^2.0.0"
 
+vue-router@^4.0.11:
+  version "4.0.11"
+  resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.11.tgz#cd649a0941c635281763a20965b599643ddc68ed"
+  integrity sha512-sha6I8fx9HWtvTrFZfxZkiQQBpqSeT+UCwauYjkdOQYRvwsGwimlQQE2ayqUwuuXGzquFpCPoXzYKWlzL4OuXg==
+  dependencies:
+    "@vue/devtools-api" "^6.0.0-beta.14"
+
 vue@^3.2.11:
   version "3.2.11"
   resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.11.tgz#6b92295048df705ddac558fd3e3ed553e55e57c8"
@@ -6324,6 +6363,13 @@ vue@^3.2.11:
     "@vue/runtime-dom" "3.2.11"
     "@vue/shared" "3.2.11"
 
+vuex@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/vuex/-/vuex-4.0.2.tgz#f896dbd5bf2a0e963f00c67e9b610de749ccacc9"
+  integrity sha512-M6r8uxELjZIK8kTKDGgZTYX/ahzblnzC4isU1tpmEuOIIKmV+TRdc+H4s8ds2NuZ7wpUTdGRzJRtoj+lI+pc0Q==
+  dependencies:
+    "@vue/devtools-api" "^6.0.0-beta.11"
+
 watchpack@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.2.0.tgz#47d78f5415fe550ecd740f99fe2882323a58b1ce"