Ver Fonte

feat: admin blocks page + lazy load blocks + block-index

NGPixel há 1 ano atrás
pai
commit
46fb25c1e9

+ 1 - 0
blocks/.gitignore

@@ -1,2 +1,3 @@
+compiled
 dist
 node_modules

+ 76 - 37
blocks/block-index/component.js

@@ -1,70 +1,109 @@
 import { LitElement, html, css } from 'lit'
-import folderByPath from './folderByPath.graphql'
+import treeQuery from './folderByPath.graphql'
 
 /**
- * An example element.
- *
- * @fires count-changed - Indicates when the count changes
- * @slot - This element has a slot
- * @csspart button - The button
+ * Block Index
  */
 export class BlockIndexElement extends LitElement {
   static get styles() {
     return css`
       :host {
         display: block;
-        border: solid 1px gray;
-        padding: 16px;
-        max-width: 800px;
+        margin-bottom: 16px;
       }
-    `;
+      :host-context(body.body--dark) {
+        background-color: #F00;
+      }
+
+      ul {
+        padding: 0;
+        margin: 0;
+        list-style: none;
+      }
+
+      li {
+        background-color: #fafafa;
+        background-image: linear-gradient(180deg,#fff,#fafafa);
+        border-right: 1px solid #eee;
+        border-bottom: 1px solid #eee;
+        border-left: 5px solid #e0e0e0;
+        box-shadow: 0 3px 8px 0 rgba(116,129,141,.1);
+        padding: 0;
+        border-radius: 5px;
+        font-weight: 500;
+      }
+      li:hover {
+        background-image: linear-gradient(180deg,#fff,#f6fbfe);
+        border-left-color: #2196f3;
+        cursor: pointer;
+      }
+      li + li {
+        margin-top: .5rem;
+      }
+      li a {
+        display: block;
+        color: #1976d2;
+        padding: 1rem;
+        text-decoration: none;
+      }
+    `
   }
 
   static get properties() {
     return {
       /**
-       * The name to say "Hello" to.
+       * The base path to fetch pages from
        * @type {string}
        */
-      name: {type: String},
+      path: {type: String},
 
       /**
-       * The number of times the button has been clicked.
+       * A comma-separated list of tags to filter with
+       * @type {string}
+       */
+      tags: {type: String},
+
+      /**
+       * The maximum number of items to fetch
        * @type {number}
        */
-      count: {type: Number},
-    };
+      limit: {type: Number}
+    }
   }
 
   constructor() {
-    super();
-    this.name = 'World';
-    this.count = 0;
+    super()
+    this.pages = []
+  }
+
+  async connectedCallback() {
+    super.connectedCallback()
+    const resp = await APOLLO_CLIENT.query({
+      query: treeQuery,
+      variables: {
+        siteId: WIKI_STORES.site.id,
+        locale: 'en',
+        parentPath: ''
+      }
+    })
+    this.pages = resp.data.tree
+    this.requestUpdate()
   }
 
   render() {
     return html`
-      <h1>${this.sayHello(this.name)}!</h1>
-      <button @click=${this._onClick} part="button">
-        Click Count: ${this.count}
-      </button>
+      <ul>
+        ${this.pages.map(p =>
+          html`<li><a href="#">${p.title}</a></li>`
+        )}
+      </ul>
       <slot></slot>
-    `;
-  }
-
-  _onClick() {
-    this.count++;
-    this.dispatchEvent(new CustomEvent('count-changed'));
+    `
   }
 
-  /**
-   * Formats a greeting
-   * @param name {string} The name to say "Hello" to
-   * @returns {string} A greeting directed at `name`
-   */
-  sayHello(name) {
-    return `Hello, ${name}`;
-  }
+  // createRenderRoot() {
+  //   return this;
+  // }
 }
 
-window.customElements.define('block-index', BlockIndexElement);
+window.customElements.define('block-index', BlockIndexElement)

+ 10 - 2
blocks/block-index/folderByPath.graphql

@@ -1,5 +1,13 @@
-query folderByPath($siteId: UUID!, $locale: String!, $path: String!) {
-  folderByPath(siteId: $siteId, locale: $locale, path: $path) {
+query blockIndexFetchPages (
+  $siteId: UUID!
+  $locale: String!
+  $parentPath: String!
+  ) {
+  tree(
+    siteId: $siteId,
+    locale: $locale,
+    parentPath: $parentPath
+    ) {
     id
     title
   }

+ 1 - 1
blocks/package-lock.json

@@ -7,7 +7,7 @@
     "": {
       "name": "blocks",
       "version": "1.0.0",
-      "license": "ISC",
+      "license": "AGPL-3.0",
       "dependencies": {
         "lit": "^2.8.0"
       },

+ 3 - 4
blocks/rollup.config.mjs

@@ -21,7 +21,7 @@ export default {
     })
   ),
   output: {
-    dir: 'dist',
+    dir: 'compiled',
     format: 'es',
     globals: {
       APOLLO_CLIENT: 'APOLLO_CLIENT'
@@ -31,9 +31,8 @@ export default {
     resolve(),
     graphql(),
     terser({
-      ecma: 2017,
-      module: true,
-      warnings: true
+      ecma: 2019,
+      module: true
     }),
     summary()
   ]

+ 27 - 0
server/db/migrations/3.0.0.mjs

@@ -70,6 +70,17 @@ export async function up (knex) {
       table.string('allowedEmailRegex')
       table.specificType('autoEnrollGroups', 'uuid[]')
     })
+    .createTable('blocks', table => {
+      table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
+      table.string('block').notNullable()
+      table.string('name').notNullable()
+      table.string('description').notNullable()
+      table.string('icon')
+      table.boolean('isEnabled').notNullable().defaultTo(false)
+      table.boolean('isCustom').notNullable().defaultTo(false)
+      table.json('config').notNullable()
+    })
+    // COMMENT PROVIDERS -------------------
     .createTable('commentProviders', table => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.string('module').notNullable()
@@ -363,6 +374,9 @@ export async function up (knex) {
       table.uuid('authorId').notNullable().references('id').inTable('users')
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
     })
+    .table('blocks', table => {
+      table.uuid('siteId').notNullable().references('id').inTable('sites')
+    })
     .table('commentProviders', table => {
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
@@ -774,6 +788,19 @@ export async function up (knex) {
     }
   ])
 
+  // -> BLOCKS
+
+  await knex('blocks').insert({
+    block: 'index',
+    name: 'Index',
+    description: 'Show a list of pages matching a path or set of tags.',
+    icon: 'rules',
+    isCustom: false,
+    isEnabled: true,
+    config: {},
+    siteId: siteId
+  })
+
   // -> NAVIGATION
 
   await knex('navigation').insert({

+ 23 - 0
server/graph/resolvers/block.mjs

@@ -0,0 +1,23 @@
+import { generateError, generateSuccess } from '../../helpers/graph.mjs'
+
+export default {
+  Query: {
+    async blocks (obj, args, context) {
+      return WIKI.db.blocks.query().where({
+        siteId: args.siteId
+      })
+    }
+  },
+  Mutation: {
+    async setBlocksState(obj, args, context) {
+      try {
+        // TODO: update blocks state
+        return {
+          operation: generateSuccess('Blocks state updated successfully')
+        }
+      } catch (err) {
+        return generateError(err)
+      }
+    }
+  }
+}

+ 40 - 0
server/graph/schemas/block.graphql

@@ -0,0 +1,40 @@
+# ===============================================
+# BLOCKS
+# ===============================================
+
+extend type Query {
+  blocks(
+    siteId: UUID!
+  ): [Block]
+}
+
+extend type Mutation {
+  setBlocksState(
+    siteId: UUID!
+    states: [BlockStateInput]!
+  ): DefaultResponse
+
+  deleteBlock(
+    id: UUID!
+  ): DefaultResponse
+}
+
+# -----------------------------------------------
+# TYPES
+# -----------------------------------------------
+
+type Block {
+  id: UUID
+  block: String
+  name: String
+  description: String
+  icon: String
+  isEnabled: Boolean
+  isCustom: Boolean
+  config: JSON
+}
+
+input BlockStateInput {
+  id: UUID!
+  isEnabled: Boolean!
+}

+ 6 - 0
server/locales/en.json

@@ -109,6 +109,12 @@
   "admin.auth.title": "Authentication",
   "admin.auth.vendor": "Vendor",
   "admin.auth.vendorWebsite": "Website",
+  "admin.blocks.add": "Add Block",
+  "admin.blocks.builtin": "Built-in component",
+  "admin.blocks.custom": "Custom component",
+  "admin.blocks.isEnabled": "Enabled",
+  "admin.blocks.saveSuccess": "Blocks state saved successfully.",
+  "admin.blocks.subtitle": "Manage dynamic components available for use inside pages.",
   "admin.blocks.title": "Content Blocks",
   "admin.comments.provider": "Provider",
   "admin.comments.providerConfig": "Provider Configuration",

+ 28 - 0
server/models/blocks.mjs

@@ -0,0 +1,28 @@
+import { Model } from 'objection'
+
+/**
+ * Block model
+ */
+export class Block extends Model {
+  static get tableName () { return 'blocks' }
+
+  static get jsonAttributes () {
+    return ['config']
+  }
+
+  static async addBlock (data) {
+    return WIKI.db.blocks.query().insertAndFetch({
+      block: data.block,
+      name: data.name,
+      description: data.description,
+      icon: data.icon,
+      isEnabled: true,
+      isCustom: true,
+      config: {}
+    })
+  }
+
+  static async deleteBlock (id) {
+    return WIKI.db.blocks.query().deleteById(id)
+  }
+}

+ 2 - 0
server/models/index.mjs

@@ -2,6 +2,7 @@ import { Analytics } from './analytics.mjs'
 import { ApiKey } from './apiKeys.mjs'
 import { Asset } from './assets.mjs'
 import { Authentication } from './authentication.mjs'
+import { Block } from './blocks.mjs'
 import { CommentProvider } from './commentProviders.mjs'
 import { Comment } from './comments.mjs'
 import { Group } from './groups.mjs'
@@ -25,6 +26,7 @@ export default {
   apiKeys: ApiKey,
   assets: Asset,
   authentication: Authentication,
+  blocks: Block,
   commentProviders: CommentProvider,
   comments: Comment,
   groups: Group,

+ 1 - 1
server/web.mjs

@@ -120,7 +120,7 @@ export async function init () {
   // Blocks
   // ----------------------------------------
 
-  app.use('/_blocks', express.static(path.join(WIKI.ROOTPATH, 'blocks/dist'), {
+  app.use('/_blocks', express.static(path.join(WIKI.ROOTPATH, 'blocks/compiled'), {
     index: false,
     maxAge: '7d'
   }))

+ 1 - 0
ux/public/_assets/icons/ultraviolet-plugin.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#98ccfd" d="M5.5 5.5H16.5V11.5H5.5z"/><path fill="#4788c7" d="M16,6v5H6V6H16 M17,5H5v7h12V5L17,5z"/><path fill="#98ccfd" d="M23.5 5.5H34.5V12.5H23.5z"/><path fill="#4788c7" d="M34,6v6H24V6H34 M35,5H23v8h12V5L35,5z"/><g><path fill="#dff0fe" d="M1.5 11.5H38.5V34.5H1.5z"/><path fill="#4788c7" d="M38,12v22H2V12H38 M39,11H1v24h38V11L39,11z"/></g></svg>

+ 1 - 0
ux/public/_assets/icons/ultraviolet-rules.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M5.5 2.5H34.5V30.577H5.5z"/><path fill="#4788c7" d="M34,3v27.077H6V3H34 M35,2H5v29.077h30V2L35,2z"/><path fill="#b6dcfe" d="M7.538,37.5c-2.108,0-4.893-0.738-5.032-7h34.988c-0.14,6.262-2.924,7-5.032,7H7.538z"/><path fill="#4788c7" d="M36.977 31c-.25 5.292-2.522 6-4.516 6H7.538c-1.994 0-4.266-.708-4.516-6H36.977M38 30H2c0 6.635 2.775 8 5.538 8 0 0 22.145 0 24.923 0C35.24 38 38 36.67 38 30L38 30zM28.5 12h-13c-.276 0-.5-.224-.5-.5v0c0-.276.224-.5.5-.5h13c.276 0 .5.224.5.5v0C29 11.776 28.776 12 28.5 12zM11.5 10.5A1 1 0 1 0 11.5 12.5 1 1 0 1 0 11.5 10.5zM28.5 18h-13c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h13c.276 0 .5.224.5.5l0 0C29 17.776 28.776 18 28.5 18zM11.5 16.5A1 1 0 1 0 11.5 18.5 1 1 0 1 0 11.5 16.5zM28.5 24h-13c-.276 0-.5-.224-.5-.5l0 0c0-.276.224-.5.5-.5h13c.276 0 .5.224.5.5l0 0C29 23.776 28.776 24 28.5 24zM11.5 22.5A1 1 0 1 0 11.5 24.5 1 1 0 1 0 11.5 22.5z"/></svg>

+ 1 - 0
ux/quasar.config.js

@@ -38,6 +38,7 @@ module.exports = configure(function (ctx) {
     boot: [
       'apollo',
       'components',
+      'externals',
       'eventbus',
       'i18n',
       {

+ 17 - 0
ux/src/boot/externals.js

@@ -0,0 +1,17 @@
+import { boot } from 'quasar/wrappers'
+import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
+
+export default boot(() => {
+  if (import.meta.env.SSR) {
+    global.WIKI_STORES = {
+      site: useSiteStore(),
+      user: useUserStore()
+    }
+  } else {
+    window.WIKI_STORES = {
+      site: useSiteStore(),
+      user: useUserStore()
+    }
+  }
+})

+ 7 - 0
ux/src/components/EditorMarkdown.vue

@@ -258,6 +258,7 @@ import { DateTime } from 'luxon'
 import * as monaco from 'monaco-editor'
 import { Position, Range } from 'monaco-editor'
 
+import { useCommonStore } from 'src/stores/common'
 import { useEditorStore } from 'src/stores/editor'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
@@ -271,6 +272,7 @@ const $q = useQuasar()
 
 // STORES
 
+const commonStore = useCommonStore()
 const editorStore = useEditorStore()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
@@ -472,6 +474,11 @@ function processContent (newContent) {
   pageStore.$patch({
     render: md.render(newContent)
   })
+  nextTick(() => {
+    for (const block of editorPreviewContainerRef.value.querySelectorAll(':not(:defined)')) {
+      commonStore.loadBlocks([block.tagName.toLowerCase()])
+    }
+  })
 }
 
 function openEditorSettings () {

+ 4 - 4
ux/src/layouts/AdminLayout.vue

@@ -98,10 +98,10 @@ q-layout.admin(view='hHh Lpr lff')
               q-item-section(avatar)
                 q-icon(name='img:/_assets/icons/fluent-comments.svg')
               q-item-section {{ t('admin.comments.title') }}
-            q-item(:to='`/_admin/` + adminStore.currentSiteId + `/blocks`', v-ripple, active-class='bg-primary text-white', disabled)
-              q-item-section(avatar)
-                q-icon(name='img:/_assets/icons/fluent-rfid-tag.svg')
-              q-item-section {{ t('admin.blocks.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/blocks`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-rfid-tag.svg')
+            q-item-section {{ t('admin.blocks.title') }}
           q-item(:to='`/_admin/` + adminStore.currentSiteId + `/editors`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`)')
             q-item-section(avatar)
               q-icon(name='img:/_assets/icons/fluent-cashbook.svg')

+ 235 - 0
ux/src/pages/AdminBlocks.vue

@@ -0,0 +1,235 @@
+<template lang='pug'>
+q-page.admin-flags
+  .row.q-pa-md.items-center
+    .col-auto
+      img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-rfid-tag.svg')
+    .col.q-pl-md
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.blocks.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.blocks.subtitle') }}
+    .col-auto.flex
+      template(v-if='flagsStore.experimental')
+        q-btn.q-mr-sm.acrylic-btn(
+          unelevated
+          icon='las la-plus'
+          :label='t(`admin.blocks.add`)'
+          color='primary'
+          @click='addBlock'
+        )
+        q-separator.q-mr-sm(vertical)
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-question-circle'
+        flat
+        color='grey'
+        :aria-label='t(`common.actions.viewDocs`)'
+        :href='siteStore.docsBase + `/admin/editors`'
+        target='_blank'
+        type='a'
+        )
+        q-tooltip {{ t(`common.actions.viewDocs`) }}
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-redo-alt'
+        flat
+        color='secondary'
+        :loading='state.loading > 0'
+        :aria-label='t(`common.actions.refresh`)'
+        @click='refresh'
+        )
+        q-tooltip {{ t(`common.actions.refresh`) }}
+      q-btn(
+        unelevated
+        icon='mdi-check'
+        :label='t(`common.actions.apply`)'
+        color='secondary'
+        @click='save'
+        :disabled='state.loading > 0'
+      )
+  q-separator(inset)
+  .q-pa-md.q-gutter-md
+    q-card
+      q-list(separator)
+        q-item(v-for='block of state.blocks', :key='block.id')
+          blueprint-icon(:icon='block.isCustom ? `plugin` : block.icon')
+          q-item-section
+            q-item-label: strong {{block.name}}
+            q-item-label(caption) {{ block.description}}
+            q-item-label.flex.items-center(caption)
+              q-chip.q-ma-none(square, dense, :color='$q.dark.isActive ? `pink-8` : `pink-1`', :text-color='$q.dark.isActive ? `white` : `pink-9`'): span.text-caption &lt;block-{{ block.block }}&gt;
+              q-separator.q-mx-sm.q-my-xs(vertical)
+              em.text-purple(v-if='block.isCustom') {{ t('admin.blocks.custom') }}
+              em.text-teal-7(v-else) {{ t('admin.blocks.builtin') }}
+          template(v-if='block.isCustom')
+            q-item-section(
+              side
+              )
+              q-btn(
+                icon='las la-trash'
+                :aria-label='t(`common.actions.delete`)'
+                color='negative'
+                outline
+                no-caps
+                padding='xs sm'
+                @click='deleteBlock(block.id)'
+              )
+            q-separator.q-ml-lg(vertical)
+          q-item-section(side)
+            q-toggle.q-pr-sm(
+              v-model='block.isEnabled'
+              color='primary'
+              checked-icon='las la-check'
+              unchecked-icon='las la-times'
+              :label='t(`admin.blocks.isEnabled`)'
+              :aria-label='t(`admin.blocks.isEnabled`)'
+              )
+</template>
+
+<script setup>
+import { useMeta, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { defineAsyncComponent, onMounted, reactive, watch } from 'vue'
+import gql from 'graphql-tag'
+import { cloneDeep, pick } from 'lodash-es'
+
+import { useAdminStore } from 'src/stores/admin'
+import { useFlagsStore } from 'src/stores/flags'
+import { useSiteStore } from 'src/stores/site'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const adminStore = useAdminStore()
+const flagsStore = useFlagsStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.editors.title')
+})
+
+const state = reactive({
+  loading: 0,
+  blocks: []
+})
+
+// WATCHERS
+
+watch(() => adminStore.currentSiteId, (newValue) => {
+  $q.loading.show()
+  load()
+})
+
+// METHODS
+
+async function load () {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query getSiteBlocks (
+          $siteId: UUID!
+        ) {
+        blocks (
+          siteId: $siteId
+        ) {
+          id
+          block
+          name
+          description
+          icon
+          isEnabled
+          isCustom
+          config
+        }
+      }`,
+      variables: {
+        siteId: adminStore.currentSiteId
+      },
+      fetchPolicy: 'network-only'
+    })
+    state.blocks = cloneDeep(resp?.data?.blocks)
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to fetch blocks state.'
+    })
+  }
+  $q.loading.hide()
+  state.loading--
+}
+
+async function save () {
+  state.loading++
+  try {
+    const respRaw = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation saveSiteBlocks (
+          $siteId: UUID!
+          $states: [BlockStateInput]!
+          ) {
+          setBlocksState (
+            siteId: $siteId,
+            states: $states
+            ) {
+            operation {
+              succeeded
+              slug
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        siteId: adminStore.currentSiteId,
+        states: state.blocks.map(bl => pick(bl, ['id', 'isEnabled']))
+      }
+    })
+    if (respRaw?.data?.setBlocksState?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.blocks.saveSuccess')
+      })
+    } else {
+      throw new Error(respRaw?.data?.setBlocksState?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to save site blocks state',
+      caption: err.message
+    })
+  }
+  state.loading--
+}
+
+async function refresh () {
+  await load()
+}
+
+function addBlock () {
+
+}
+
+function deleteBlock (id) {
+
+}
+
+// MOUNTED
+
+onMounted(async () => {
+  $q.loading.show()
+  if (adminStore.currentSiteId) {
+    await load()
+  }
+})
+</script>
+
+<style lang='scss'>
+
+</style>

+ 12 - 2
ux/src/pages/Index.vue

@@ -43,7 +43,7 @@ q-page.column
         style='height: 100%;'
         )
         .q-pa-md
-          .page-contents(v-html='pageStore.render')
+          .page-contents(ref='pageContents', v-html='pageStore.render')
           template(v-if='pageStore.relations && pageStore.relations.length > 0')
             q-separator.q-my-lg
             .row.align-center
@@ -158,11 +158,12 @@ q-page.column
 
 <script setup>
 import { useMeta, useQuasar } from 'quasar'
-import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
+import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import { DateTime } from 'luxon'
 
+import { useCommonStore } from 'src/stores/common'
 import { useEditorStore } from 'src/stores/editor'
 import { useFlagsStore } from 'src/stores/flags'
 import { usePageStore } from 'src/stores/page'
@@ -194,6 +195,7 @@ const $q = useQuasar()
 
 // STORES
 
+const commonStore = useCommonStore()
 const editorStore = useEditorStore()
 const flagsStore = useFlagsStore()
 const pageStore = usePageStore()
@@ -241,6 +243,8 @@ const barStyle = {
   opacity: 1
 }
 
+const pageContents = ref(null)
+
 // COMPUTED
 
 const showSidebar = computed(() => {
@@ -312,6 +316,12 @@ watch(() => route.path, async (newValue) => {
         isActive: false
       })
     }
+    // -> Load Blocks
+    nextTick(() => {
+      for (const block of pageContents.value.querySelectorAll(':not(:defined)')) {
+        commonStore.loadBlocks([block.tagName.toLowerCase()])
+      }
+    })
   } catch (err) {
     if (err.message === 'ERR_PAGE_NOT_FOUND') {
       if (newValue === '/') {

+ 1 - 0
ux/src/router/routes.js

@@ -45,6 +45,7 @@ const routes = [
       { path: 'sites', component: () => import('pages/AdminSites.vue') },
       // -> Site
       { path: ':siteid/general', component: () => import('pages/AdminGeneral.vue') },
+      { path: ':siteid/blocks', component: () => import('pages/AdminBlocks.vue') },
       { path: ':siteid/editors', component: () => import('pages/AdminEditors.vue') },
       { path: ':siteid/locale', component: () => import('pages/AdminLocale.vue') },
       { path: ':siteid/login', component: () => import('pages/AdminLogin.vue') },

+ 14 - 1
ux/src/stores/common.js

@@ -1,11 +1,13 @@
 import { defineStore } from 'pinia'
 import gql from 'graphql-tag'
+import { difference } from 'lodash-es'
 
 export const useCommonStore = defineStore('common', {
   state: () => ({
     routerLoading: false,
     locale: localStorage.getItem('locale') || 'en',
-    desiredLocale: localStorage.getItem('locale')
+    desiredLocale: localStorage.getItem('locale'),
+    blocksLoaded: []
   }),
   getters: {},
   actions: {
@@ -38,6 +40,17 @@ export const useCommonStore = defineStore('common', {
         desiredLocale: locale
       })
       localStorage.setItem('locale', locale)
+    },
+    async loadBlocks (blocks = []) {
+      const toLoad = difference(blocks, this.blocksLoaded)
+      for (const block of toLoad) {
+        try {
+          await import(/* @vite-ignore */ `/_blocks/${block}.js`)
+          this.blocksLoaded.push(block)
+        } catch (err) {
+          console.warn(`Failed to load ${block}: ${err.message}`)
+        }
+      }
     }
   }
 })