Explorar o código

feat: rendering + new page view

Nicolas Giard %!s(int64=2) %!d(string=hai) anos
pai
achega
7128b160dd
Modificáronse 48 ficheiros con 957 adicións e 573 borrados
  1. 8 1
      server/app/data.yml
  2. 16 15
      server/controllers/common.js
  3. 8 4
      server/core/db.js
  4. 72 11
      server/core/scheduler.js
  5. 1 4
      server/db/migrations/3.0.0.js
  6. 52 0
      server/graph/resolvers/system.js
  7. 8 0
      server/graph/schemas/system.graphql
  8. 28 0
      server/helpers/common.js
  9. 0 41
      server/models/editors.js
  10. 27 18
      server/models/pages.js
  11. 51 5
      server/models/renderers.js
  12. 1 1
      server/modules/rendering/html-asciinema/definition.yml
  13. 1 1
      server/modules/rendering/html-blockquotes/definition.yml
  14. 1 1
      server/modules/rendering/html-codehighlighter/definition.yml
  15. 1 1
      server/modules/rendering/html-core/definition.yml
  16. 5 8
      server/modules/rendering/html-core/renderer.js
  17. 1 1
      server/modules/rendering/html-diagram/definition.yml
  18. 1 1
      server/modules/rendering/html-image-prefetch/definition.yml
  19. 1 1
      server/modules/rendering/html-mediaplayers/definition.yml
  20. 1 1
      server/modules/rendering/html-mermaid/definition.yml
  21. 1 1
      server/modules/rendering/html-security/definition.yml
  22. 1 1
      server/modules/rendering/html-tabset/definition.yml
  23. 1 1
      server/modules/rendering/html-twemoji/definition.yml
  24. 1 1
      server/modules/rendering/markdown-abbr/definition.yml
  25. 1 1
      server/modules/rendering/markdown-core/definition.yml
  26. 1 1
      server/modules/rendering/markdown-core/renderer.js
  27. 1 1
      server/modules/rendering/markdown-emoji/definition.yml
  28. 1 1
      server/modules/rendering/markdown-expandtabs/definition.yml
  29. 1 1
      server/modules/rendering/markdown-footnotes/definition.yml
  30. 1 1
      server/modules/rendering/markdown-imsize/definition.yml
  31. 1 1
      server/modules/rendering/markdown-katex/definition.yml
  32. 1 1
      server/modules/rendering/markdown-kroki/definition.yml
  33. 1 1
      server/modules/rendering/markdown-mathjax/definition.yml
  34. 1 1
      server/modules/rendering/markdown-multi-table/definition.yml
  35. 1 1
      server/modules/rendering/markdown-plantuml/definition.yml
  36. 1 1
      server/modules/rendering/markdown-supsub/definition.yml
  37. 1 1
      server/modules/rendering/markdown-tasklists/definition.yml
  38. 92 0
      server/tasks/workers/render-page.js
  39. 0 13
      server/views/page.pug
  40. 17 1
      server/worker.js
  41. 43 28
      ux/src/components/PageTags.vue
  42. 107 86
      ux/src/components/SocialSharingMenu.vue
  43. 5 1
      ux/src/i18n/locales/en.json
  44. 5 5
      ux/src/layouts/AdminLayout.vue
  45. 56 38
      ux/src/layouts/MainLayout.vue
  46. 111 3
      ux/src/pages/AdminScheduler.vue
  47. 207 256
      ux/src/pages/Index.vue
  48. 13 10
      ux/src/router/routes.js

+ 8 - 1
server/app/data.yml

@@ -33,7 +33,7 @@ defaults:
       workers: 3
       workers: 3
       pollingCheck: 5
       pollingCheck: 5
       scheduledCheck: 300
       scheduledCheck: 300
-      maxRetries: 5
+      maxRetries: 2
       retryBackoff: 60
       retryBackoff: 60
       historyExpiration: 90000
       historyExpiration: 90000
     # DB defaults
     # DB defaults
@@ -83,6 +83,13 @@ defaults:
     search:
     search:
       maxHits: 100
       maxHits: 100
     maintainerEmail: security@requarks.io
     maintainerEmail: security@requarks.io
+editors:
+  code:
+    contentType: html
+  markdown:
+    contentType: markdown
+  wysiwyg:
+    contentType: html
 groups:
 groups:
   defaultPermissions:
   defaultPermissions:
     - 'read:pages'
     - 'read:pages'

+ 16 - 15
server/controllers/common.js

@@ -528,9 +528,9 @@ router.get('/*', async (req, res, next) => {
 
 
         // -> Build theme code injection
         // -> Build theme code injection
         const injectCode = {
         const injectCode = {
-          css: WIKI.config.theming.injectCSS,
-          head: WIKI.config.theming.injectHead,
-          body: WIKI.config.theming.injectBody
+          css: '', // WIKI.config.theming.injectCSS,
+          head: '', // WIKI.config.theming.injectHead,
+          body: '' // WIKI.config.theming.injectBody
         }
         }
 
 
         // Handle missing extra field
         // Handle missing extra field
@@ -551,12 +551,12 @@ router.get('/*', async (req, res, next) => {
 
 
         // -> Inject comments variables
         // -> Inject comments variables
         const commentTmpl = {
         const commentTmpl = {
-          codeTemplate: WIKI.data.commentProvider.codeTemplate,
-          head: WIKI.data.commentProvider.head,
-          body: WIKI.data.commentProvider.body,
-          main: WIKI.data.commentProvider.main
+          codeTemplate: '', // WIKI.data.commentProvider.codeTemplate,
+          head: '', // WIKI.data.commentProvider.head,
+          body: '', // WIKI.data.commentProvider.body,
+          main: '' // WIKI.data.commentProvider.main
         }
         }
-        if (WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
+        if (false && WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
           [
           [
             { key: 'pageUrl', value: `${WIKI.config.host}/i/${page.id}` },
             { key: 'pageUrl', value: `${WIKI.config.host}/i/${page.id}` },
             { key: 'pageId', value: page.id }
             { key: 'pageId', value: page.id }
@@ -568,13 +568,14 @@ router.get('/*', async (req, res, next) => {
         }
         }
 
 
         // -> Render view
         // -> Render view
-        res.render('page', {
-          page,
-          sidebar,
-          injectCode,
-          comments: commentTmpl,
-          effectivePermissions
-        })
+        res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
+        // res.render('page', {
+        //   page,
+        //   sidebar,
+        //   injectCode,
+        //   comments: commentTmpl,
+        //   effectivePermissions
+        // })
       } else if (pageArgs.path === 'home') {
       } else if (pageArgs.path === 'home') {
         res.redirect('/_welcome')
         res.redirect('/_welcome')
       } else {
       } else {

+ 8 - 4
server/core/db.js

@@ -21,7 +21,7 @@ module.exports = {
   /**
   /**
    * Initialize DB
    * Initialize DB
    */
    */
-  init() {
+  init(workerMode = false) {
     let self = this
     let self = this
 
 
     WIKI.logger.info('Checking DB configuration...')
     WIKI.logger.info('Checking DB configuration...')
@@ -85,10 +85,14 @@ module.exports = {
       connection: this.config,
       connection: this.config,
       searchPath: [WIKI.config.db.schemas.wiki],
       searchPath: [WIKI.config.db.schemas.wiki],
       pool: {
       pool: {
-        ...WIKI.config.pool,
+        ...workerMode ? { min: 0, max: 1 } : WIKI.config.pool,
         async afterCreate(conn, done) {
         async afterCreate(conn, done) {
           // -> Set Connection App Name
           // -> Set Connection App Name
-          await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}:MAIN'`)
+          if (workerMode) {
+            await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}'`)
+          } else {
+            await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}:MAIN'`)
+          }
           done()
           done()
         }
         }
       },
       },
@@ -145,7 +149,7 @@ module.exports = {
 
 
     // Perform init tasks
     // Perform init tasks
 
 
-    this.onReady = (async () => {
+    this.onReady = workerMode ? Promise.resolve() : (async () => {
       await initTasks.connect()
       await initTasks.connect()
       await initTasks.migrateFromLegacy()
       await initTasks.migrateFromLegacy()
       await initTasks.syncSchemas()
       await initTasks.syncSchemas()

+ 72 - 11
server/core/scheduler.js

@@ -4,6 +4,9 @@ const autoload = require('auto-load')
 const path = require('node:path')
 const path = require('node:path')
 const cronparser = require('cron-parser')
 const cronparser = require('cron-parser')
 const { DateTime } = require('luxon')
 const { DateTime } = require('luxon')
+const { v4: uuid } = require('uuid')
+const { createDeferred } = require('../helpers/common')
+const _ = require('lodash')
 
 
 module.exports = {
 module.exports = {
   workerPool: null,
   workerPool: null,
@@ -12,6 +15,7 @@ module.exports = {
   pollingRef: null,
   pollingRef: null,
   scheduledRef: null,
   scheduledRef: null,
   tasks: null,
   tasks: null,
+  completionPromises: [],
   async init () {
   async init () {
     this.maxWorkers = WIKI.config.scheduler.workers === 'auto' ? (os.cpus().length - 1) : WIKI.config.scheduler.workers
     this.maxWorkers = WIKI.config.scheduler.workers === 'auto' ? (os.cpus().length - 1) : WIKI.config.scheduler.workers
     if (this.maxWorkers < 1) { this.maxWorkers = 1 }
     if (this.maxWorkers < 1) { this.maxWorkers = 1 }
@@ -38,6 +42,20 @@ module.exports = {
           }
           }
           break
           break
         }
         }
+        case 'jobCompleted': {
+          const jobPromise = _.find(this.completionPromises, ['id', payload.id])
+          if (jobPromise) {
+            if (payload.state === 'success') {
+              jobPromise.resolve()
+            } else {
+              jobPromise.reject(new Error(payload.errorMessage))
+            }
+            setTimeout(() => {
+              _.remove(this.completionPromises, ['id', payload.id])
+            })
+          }
+          break
+        }
       }
       }
     })
     })
 
 
@@ -56,23 +74,52 @@ module.exports = {
 
 
     WIKI.logger.info('Scheduler: [ STARTED ]')
     WIKI.logger.info('Scheduler: [ STARTED ]')
   },
   },
-  async addJob ({ task, payload, waitUntil, maxRetries, isScheduled = false, notify = true }) {
+  /**
+   * Add a job to the scheduler
+   * @param {Object} opts - Job options
+   * @param {string} opts.task - The task name to execute.
+   * @param {Object} [opts.payload={}] - An optional data object to pass to the job.
+   * @param {Date} [opts.waitUntil] - An optional datetime after which the task is allowed to run.
+   * @param {Number} [opts.maxRetries] - The number of times this job can be restarted upon failure. Uses server defaults if not provided.
+   * @param {Boolean} [opts.isScheduled=false] - Whether this is a scheduled job.
+   * @param {Boolean} [opts.notify=true] - Whether to notify all instances that a new job is available.
+   * @param {Boolean} [opts.promise=false] - Whether to return a promise property that resolves when the job completes.
+   * @returns {Promise}
+   */
+  async addJob ({ task, payload = {}, waitUntil, maxRetries, isScheduled = false, notify = true, promise = false }) {
     try {
     try {
-      await WIKI.db.knex('jobs').insert({
-        task,
-        useWorker: !(typeof this.tasks[task] === 'function'),
-        payload,
-        maxRetries: maxRetries ?? WIKI.config.scheduler.maxRetries,
-        isScheduled,
-        waitUntil,
-        createdBy: WIKI.INSTANCE_ID
-      })
+      const jobId = uuid()
+      const jobDefer = createDeferred()
+      if (promise) {
+        this.completionPromises.push({
+          id: jobId,
+          added: DateTime.utc(),
+          resolve: jobDefer.resolve,
+          reject: jobDefer.reject
+        })
+      }
+      await WIKI.db.knex('jobs')
+        .insert({
+          id: jobId,
+          task,
+          useWorker: !(typeof this.tasks[task] === 'function'),
+          payload,
+          maxRetries: maxRetries ?? WIKI.config.scheduler.maxRetries,
+          isScheduled,
+          waitUntil,
+          createdBy: WIKI.INSTANCE_ID
+        })
       if (notify) {
       if (notify) {
         WIKI.db.listener.publish('scheduler', {
         WIKI.db.listener.publish('scheduler', {
           source: WIKI.INSTANCE_ID,
           source: WIKI.INSTANCE_ID,
-          event: 'newJob'
+          event: 'newJob',
+          id: jobId
         })
         })
       }
       }
+      return {
+        id: jobId,
+        ...promise && { promise: jobDefer.promise }
+      }
     } catch (err) {
     } catch (err) {
       WIKI.logger.warn(`Failed to add job to scheduler: ${err.message}`)
       WIKI.logger.warn(`Failed to add job to scheduler: ${err.message}`)
     }
     }
@@ -130,6 +177,12 @@ module.exports = {
                 completedAt: new Date()
                 completedAt: new Date()
               })
               })
               WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
               WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
+              WIKI.db.listener.publish('scheduler', {
+                source: WIKI.INSTANCE_ID,
+                event: 'jobCompleted',
+                state: 'success',
+                id: job.id
+              })
             } catch (err) {
             } catch (err) {
               WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
               WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
               WIKI.logger.warn(err)
               WIKI.logger.warn(err)
@@ -137,9 +190,17 @@ module.exports = {
               await WIKI.db.knex('jobHistory').where({
               await WIKI.db.knex('jobHistory').where({
                 id: job.id
                 id: job.id
               }).update({
               }).update({
+                attempt: job.retries + 1,
                 state: 'failed',
                 state: 'failed',
                 lastErrorMessage: err.message
                 lastErrorMessage: err.message
               })
               })
+              WIKI.db.listener.publish('scheduler', {
+                source: WIKI.INSTANCE_ID,
+                event: 'jobCompleted',
+                state: 'failed',
+                id: job.id,
+                errorMessage: err.message
+              })
               // -> Reschedule for retry
               // -> Reschedule for retry
               if (job.retries < job.maxRetries) {
               if (job.retries < job.maxRetries) {
                 const backoffDelay = (2 ** job.retries) * WIKI.config.scheduler.retryBackoff
                 const backoffDelay = (2 ** job.retries) * WIKI.config.scheduler.retryBackoff

+ 1 - 4
server/db/migrations/3.0.0.js

@@ -243,7 +243,7 @@ exports.up = async knex => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.string('module').notNullable()
       table.string('module').notNullable()
       table.boolean('isEnabled').notNullable().defaultTo(false)
       table.boolean('isEnabled').notNullable().defaultTo(false)
-      table.jsonb('config')
+      table.jsonb('config').notNullable().defaultTo('{}')
     })
     })
     // SETTINGS ----------------------------
     // SETTINGS ----------------------------
     .createTable('settings', table => {
     .createTable('settings', table => {
@@ -370,9 +370,6 @@ exports.up = async knex => {
       table.uuid('pageId').notNullable().references('id').inTable('pages').onDelete('CASCADE')
       table.uuid('pageId').notNullable().references('id').inTable('pages').onDelete('CASCADE')
       table.string('localeCode', 5).references('code').inTable('locales')
       table.string('localeCode', 5).references('code').inTable('locales')
     })
     })
-    .table('renderers', table => {
-      table.uuid('siteId').notNullable().references('id').inTable('sites')
-    })
     .table('storage', table => {
     .table('storage', table => {
       table.uuid('siteId').notNullable().references('id').inTable('sites')
       table.uuid('siteId').notNullable().references('id').inTable('sites')
     })
     })

+ 52 - 0
server/graph/resolvers/system.js

@@ -79,6 +79,25 @@ module.exports = {
     }
     }
   },
   },
   Mutation: {
   Mutation: {
+    async cancelJob (obj, args, context) {
+      WIKI.logger.info(`Admin requested cancelling job ${args.id}...`)
+      try {
+        const result = await WIKI.db.knex('jobs')
+          .where('id', args.id)
+          .del()
+        if (result === 1) {
+          WIKI.logger.info(`Cancelled job ${args.id} [ OK ]`)
+        } else {
+          throw new Error('Job has already entered active state or does not exist.')
+        }
+        return {
+          operation: graphHelper.generateSuccess('Cancelled job successfully.')
+        }
+      } catch (err) {
+        WIKI.logger.warn(err)
+        return graphHelper.generateError(err)
+      }
+    },
     async disconnectWS (obj, args, context) {
     async disconnectWS (obj, args, context) {
       WIKI.servers.ws.disconnectSockets(true)
       WIKI.servers.ws.disconnectSockets(true)
       WIKI.logger.info('All active websocket connections have been terminated.')
       WIKI.logger.info('All active websocket connections have been terminated.')
@@ -97,6 +116,39 @@ module.exports = {
         return graphHelper.generateError(err)
         return graphHelper.generateError(err)
       }
       }
     },
     },
+    async retryJob (obj, args, context) {
+      WIKI.logger.info(`Admin requested rescheduling of job ${args.id}...`)
+      try {
+        const job = await WIKI.db.knex('jobHistory')
+          .where('id', args.id)
+          .first()
+        if (!job) {
+          throw new Error('No such job found.')
+        } else if (job.state === 'interrupted') {
+          throw new Error('Cannot reschedule a task that has been interrupted. It will automatically be retried shortly.')
+        } else if (job.state === 'failed' && job.attempt < job.maxRetries) {
+          throw new Error('Cannot reschedule a task that has not reached its maximum retry attempts.')
+        }
+        await WIKI.db.knex('jobs')
+          .insert({
+            id: job.id,
+            task: job.task,
+            useWorker: job.useWorker,
+            payload: job.payload,
+            retries: job.attempt,
+            maxRetries: job.maxRetries,
+            isScheduled: job.wasScheduled,
+            createdBy: WIKI.INSTANCE_ID
+          })
+        WIKI.logger.info(`Job ${args.id} has been rescheduled [ OK ]`)
+        return {
+          operation: graphHelper.generateSuccess('Job rescheduled successfully.')
+        }
+      } catch (err) {
+        WIKI.logger.warn(err)
+        return graphHelper.generateError(err)
+      }
+    },
     async updateSystemFlags (obj, args, context) {
     async updateSystemFlags (obj, args, context) {
       WIKI.config.flags = _.transform(args.flags, (result, row) => {
       WIKI.config.flags = _.transform(args.flags, (result, row) => {
         _.set(result, row.key, row.value)
         _.set(result, row.key, row.value)

+ 8 - 0
server/graph/schemas/system.graphql

@@ -16,12 +16,20 @@ extend type Query {
 }
 }
 
 
 extend type Mutation {
 extend type Mutation {
+  cancelJob(
+    id: UUID!
+  ): DefaultResponse
+
   disconnectWS: DefaultResponse
   disconnectWS: DefaultResponse
 
 
   installExtension(
   installExtension(
     key: String!
     key: String!
   ): DefaultResponse
   ): DefaultResponse
 
 
+  retryJob(
+    id: UUID!
+  ): DefaultResponse
+
   updateSystemFlags(
   updateSystemFlags(
     flags: [SystemFlagInput]!
     flags: [SystemFlagInput]!
   ): DefaultResponse
   ): DefaultResponse

+ 28 - 0
server/helpers/common.js

@@ -1,6 +1,34 @@
 const _ = require('lodash')
 const _ = require('lodash')
 
 
 module.exports = {
 module.exports = {
+  /* eslint-disable promise/param-names */
+  createDeferred () {
+    let result, resolve, reject
+    return {
+      resolve: function (value) {
+        if (resolve) {
+          resolve(value)
+        } else {
+          result = result || new Promise(function (r) { r(value) })
+        }
+      },
+      reject: function (reason) {
+        if (reject) {
+          reject(reason)
+        } else {
+          result = result || new Promise(function (x, j) { j(reason) })
+        }
+      },
+      promise: new Promise(function (r, j) {
+        if (result) {
+          r(result)
+        } else {
+          resolve = r
+          reject = j
+        }
+      })
+    }
+  },
   /**
   /**
    * Get default value of type
    * Get default value of type
    *
    *

+ 0 - 41
server/models/editors.js

@@ -1,41 +0,0 @@
-const Model = require('objection').Model
-
-/**
- * Editor model
- */
-module.exports = class Editor extends Model {
-  static get tableName() { return 'editors' }
-  static get idColumn() { return 'key' }
-
-  static get jsonSchema () {
-    return {
-      type: 'object',
-      required: ['key', 'isEnabled'],
-
-      properties: {
-        key: {type: 'string'},
-        isEnabled: {type: 'boolean'}
-      }
-    }
-  }
-
-  static get jsonAttributes() {
-    return ['config']
-  }
-
-  static async getEditors() {
-    return WIKI.db.editors.query()
-  }
-
-  static async getDefaultEditor(contentType) {
-    // TODO - hardcoded for now
-    switch (contentType) {
-      case 'markdown':
-        return 'markdown'
-      case 'html':
-        return 'ckeditor'
-      default:
-        return 'code'
-    }
-  }
-}

+ 27 - 18
server/models/pages.js

@@ -34,7 +34,7 @@ module.exports = class Page extends Model {
       required: ['path', 'title'],
       required: ['path', 'title'],
 
 
       properties: {
       properties: {
-        id: {type: 'integer'},
+        id: {type: 'string'},
         path: {type: 'string'},
         path: {type: 'string'},
         hash: {type: 'string'},
         hash: {type: 'string'},
         title: {type: 'string'},
         title: {type: 'string'},
@@ -44,7 +44,7 @@ module.exports = class Page extends Model {
         publishEndDate: {type: 'string'},
         publishEndDate: {type: 'string'},
         content: {type: 'string'},
         content: {type: 'string'},
         contentType: {type: 'string'},
         contentType: {type: 'string'},
-
+        siteId: {type: 'string'},
         createdAt: {type: 'string'},
         createdAt: {type: 'string'},
         updatedAt: {type: 'string'}
         updatedAt: {type: 'string'}
       }
       }
@@ -125,11 +125,11 @@ module.exports = class Page extends Model {
    */
    */
   static get cacheSchema() {
   static get cacheSchema() {
     return new JSBinType({
     return new JSBinType({
-      id: 'uint',
-      authorId: 'uint',
+      id: 'string',
+      authorId: 'string',
       authorName: 'string',
       authorName: 'string',
       createdAt: 'string',
       createdAt: 'string',
-      creatorId: 'uint',
+      creatorId: 'string',
       creatorName: 'string',
       creatorName: 'string',
       description: 'string',
       description: 'string',
       editor: 'string',
       editor: 'string',
@@ -137,6 +137,7 @@ module.exports = class Page extends Model {
       publishEndDate: 'string',
       publishEndDate: 'string',
       publishStartDate: 'string',
       publishStartDate: 'string',
       render: 'string',
       render: 'string',
+      siteId: 'string',
       tags: [
       tags: [
         {
         {
           tag: 'string'
           tag: 'string'
@@ -291,7 +292,7 @@ module.exports = class Page extends Model {
       authorId: opts.user.id,
       authorId: opts.user.id,
       content: opts.content,
       content: opts.content,
       creatorId: opts.user.id,
       creatorId: opts.user.id,
-      contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
+      contentType: _.get(WIKI.data.editors[opts.editor], 'contentType', 'text'),
       description: opts.description,
       description: opts.description,
       editor: opts.editor,
       editor: opts.editor,
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
@@ -322,6 +323,9 @@ module.exports = class Page extends Model {
     // -> Render page to HTML
     // -> Render page to HTML
     await WIKI.db.pages.renderPage(page)
     await WIKI.db.pages.renderPage(page)
 
 
+    return page
+    // TODO: Handle remaining flow
+
     // -> Rebuild page tree
     // -> Rebuild page tree
     await WIKI.db.pages.rebuildTree()
     await WIKI.db.pages.rebuildTree()
 
 
@@ -922,12 +926,15 @@ module.exports = class Page extends Model {
    * @returns {Promise} Promise with no value
    * @returns {Promise} Promise with no value
    */
    */
   static async renderPage(page) {
   static async renderPage(page) {
-    const renderJob = await WIKI.scheduler.registerJob({
-      name: 'render-page',
-      immediate: true,
-      worker: true
-    }, page.id)
-    return renderJob.finished
+    const renderJob = await WIKI.scheduler.addJob({
+      task: 'render-page',
+      payload: {
+        id: page.id
+      },
+      maxRetries: 0,
+      promise: true
+    })
+    return renderJob.promise
   }
   }
 
 
   /**
   /**
@@ -963,7 +970,7 @@ module.exports = class Page extends Model {
    * @returns {Promise} Promise of the Page Model Instance
    * @returns {Promise} Promise of the Page Model Instance
    */
    */
   static async getPageFromDb(opts) {
   static async getPageFromDb(opts) {
-    const queryModeID = _.isNumber(opts)
+    const queryModeID = typeof opts === 'string'
     try {
     try {
       return WIKI.db.pages.query()
       return WIKI.db.pages.query()
         .column([
         .column([
@@ -985,6 +992,7 @@ module.exports = class Page extends Model {
           'pages.localeCode',
           'pages.localeCode',
           'pages.authorId',
           'pages.authorId',
           'pages.creatorId',
           'pages.creatorId',
+          'pages.siteId',
           'pages.extra',
           'pages.extra',
           {
           {
             authorName: 'author.name',
             authorName: 'author.name',
@@ -1033,7 +1041,7 @@ module.exports = class Page extends Model {
       id: page.id,
       id: page.id,
       authorId: page.authorId,
       authorId: page.authorId,
       authorName: page.authorName,
       authorName: page.authorName,
-      createdAt: page.createdAt,
+      createdAt: page.createdAt.toISOString(),
       creatorId: page.creatorId,
       creatorId: page.creatorId,
       creatorName: page.creatorName,
       creatorName: page.creatorName,
       description: page.description,
       description: page.description,
@@ -1042,14 +1050,15 @@ module.exports = class Page extends Model {
         css: _.get(page, 'extra.css', ''),
         css: _.get(page, 'extra.css', ''),
         js: _.get(page, 'extra.js', '')
         js: _.get(page, 'extra.js', '')
       },
       },
-      publishState: page.publishState,
-      publishEndDate: page.publishEndDate,
-      publishStartDate: page.publishStartDate,
+      publishState: page.publishState ?? '',
+      publishEndDate: page.publishEndDate ?? '',
+      publishStartDate: page.publishStartDate ?? '',
       render: page.render,
       render: page.render,
+      siteId: page.siteId,
       tags: page.tags.map(t => _.pick(t, ['tag'])),
       tags: page.tags.map(t => _.pick(t, ['tag'])),
       title: page.title,
       title: page.title,
       toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
       toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
-      updatedAt: page.updatedAt
+      updatedAt: page.updatedAt.toISOString()
     }))
     }))
   }
   }
 
 

+ 51 - 5
server/models/renderers.js

@@ -55,19 +55,65 @@ module.exports = class Renderer extends Model {
   }
   }
 
 
   static async refreshRenderersFromDisk() {
   static async refreshRenderersFromDisk() {
-    // const dbRenderers = await WIKI.db.renderers.query()
+    try {
+      const dbRenderers = await WIKI.db.renderers.query()
+
+      // -> Fetch definitions from disk
+      await WIKI.db.renderers.fetchDefinitions()
+
+      // -> Insert new Renderers
+      const newRenderers = []
+      let updatedRenderers = 0
+      for (const renderer of WIKI.data.renderers) {
+        if (!_.some(dbRenderers, ['module', renderer.key])) {
+          newRenderers.push({
+            module: renderer.key,
+            isEnabled: renderer.enabledDefault ?? true,
+            config: _.transform(renderer.props, (result, value, key) => {
+              result[key] = value.default
+              return result
+            }, {})
+          })
+        } else {
+          const rendererConfig = _.get(_.find(dbRenderers, ['module', renderer.key]), 'config', {})
+          await WIKI.db.renderers.query().patch({
+            config: _.transform(renderer.props, (result, value, key) => {
+              if (!_.has(result, key)) {
+                result[key] = value.default
+              }
+              return result
+            }, rendererConfig)
+          }).where('module', renderer.key)
+          updatedRenderers++
+        }
+      }
+      if (newRenderers.length > 0) {
+        await WIKI.db.renderers.query().insert(newRenderers)
+        WIKI.logger.info(`Loaded ${newRenderers.length} new renderers: [ OK ]`)
+      }
 
 
-    // -> Fetch definitions from disk
-    await WIKI.db.renderers.fetchDefinitions()
+      if (updatedRenderers > 0) {
+        WIKI.logger.info(`Updated ${updatedRenderers} existing renderers: [ OK ]`)
+      }
 
 
-    // TODO: Merge existing configs with updated modules
+      // -> Delete removed Renderers
+      for (const renderer of dbRenderers) {
+        if (!_.some(WIKI.data.renderers, ['key', renderer.module])) {
+          await WIKI.db.renderers.query().where('module', renderer.key).del()
+          WIKI.logger.info(`Removed renderer ${renderer.key} because it is no longer present in the modules folder: [ OK ]`)
+        }
+      }
+    } catch (err) {
+      WIKI.logger.error('Failed to import renderers: [ FAILED ]')
+      WIKI.logger.error(err)
+    }
   }
   }
 
 
   static async getRenderingPipeline(contentType) {
   static async getRenderingPipeline(contentType) {
     const renderersDb = await WIKI.db.renderers.query().where('isEnabled', true)
     const renderersDb = await WIKI.db.renderers.query().where('isEnabled', true)
     if (renderersDb && renderersDb.length > 0) {
     if (renderersDb && renderersDb.length > 0) {
       const renderers = renderersDb.map(rdr => {
       const renderers = renderersDb.map(rdr => {
-        const renderer = _.find(WIKI.data.renderers, ['key', rdr.key])
+        const renderer = _.find(WIKI.data.renderers, ['key', rdr.module])
         return {
         return {
           ...renderer,
           ...renderer,
           config: rdr.config
           config: rdr.config

+ 1 - 1
server/modules/rendering/html-asciinema/definition.yml

@@ -4,5 +4,5 @@ description: Embed asciinema players from compatible links
 author: requarks.io
 author: requarks.io
 icon: mdi-theater
 icon: mdi-theater
 enabledDefault: false
 enabledDefault: false
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-blockquotes/definition.yml

@@ -4,5 +4,5 @@ description: Parse blockquotes box styling
 author: requarks.io
 author: requarks.io
 icon: mdi-alpha-t-box-outline
 icon: mdi-alpha-t-box-outline
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-codehighlighter/definition.yml

@@ -4,6 +4,6 @@ description: Syntax detector for programming code
 author: requarks.io
 author: requarks.io
 icon: mdi-code-braces
 icon: mdi-code-braces
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 step: pre
 step: pre
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-core/definition.yml

@@ -1,4 +1,4 @@
-key: htmlCore
+key: html-core
 title: Core
 title: Core
 description: Basic HTML Parser
 description: Basic HTML Parser
 author: requarks.io
 author: requarks.io

+ 5 - 8
server/modules/rendering/html-core/renderer.js

@@ -21,7 +21,7 @@ module.exports = {
     // --------------------------------
     // --------------------------------
 
 
     for (let child of _.reject(this.children, ['step', 'post'])) {
     for (let child of _.reject(this.children, ['step', 'post'])) {
-      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
+      const renderer = require(`../${child.key}/renderer.js`)
       await renderer.init($, child.config)
       await renderer.init($, child.config)
     }
     }
 
 
@@ -33,10 +33,7 @@ module.exports = {
     const reservedPrefixes = /^\/[a-z]\//i
     const reservedPrefixes = /^\/[a-z]\//i
     const exactReservedPaths = /^\/[a-z]$/i
     const exactReservedPaths = /^\/[a-z]$/i
 
 
-    const isHostSet = WIKI.config.host.length > 7 && WIKI.config.host !== 'http://'
-    if (!isHostSet) {
-      WIKI.logger.warn('Host is not set. You must set the Site Host under General in the Administration Area!')
-    }
+    const hasHostname = this.site.hostname !== '*'
 
 
     $('a').each((i, elm) => {
     $('a').each((i, elm) => {
       let href = $(elm).attr('href')
       let href = $(elm).attr('href')
@@ -48,8 +45,8 @@ module.exports = {
       }
       }
 
 
       // -> Strip host from local links
       // -> Strip host from local links
-      if (isHostSet && href.indexOf(`${WIKI.config.host}/`) === 0) {
-        href = href.replace(WIKI.config.host, '')
+      if (hasHostname && href.indexOf(`${this.site.hostname}/`) === 0) {
+        href = href.replace(this.site.hostname, '')
       }
       }
 
 
       // -> Assign local / external tag
       // -> Assign local / external tag
@@ -68,7 +65,7 @@ module.exports = {
           let pagePath = null
           let pagePath = null
 
 
           // -> Add locale prefix if using namespacing
           // -> Add locale prefix if using namespacing
-          if (WIKI.config.lang.namespacing) {
+          if (this.site.config.localeNamespacing) {
             // -> Reformat paths
             // -> Reformat paths
             if (href.indexOf('/') !== 0) {
             if (href.indexOf('/') !== 0) {
               if (this.config.absoluteLinks) {
               if (this.config.absoluteLinks) {

+ 1 - 1
server/modules/rendering/html-diagram/definition.yml

@@ -4,5 +4,5 @@ description: HTML Processing for diagrams (draw.io)
 author: requarks.io
 author: requarks.io
 icon: mdi-chart-multiline
 icon: mdi-chart-multiline
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-image-prefetch/definition.yml

@@ -4,5 +4,5 @@ description: Prefetch remotely rendered images (korki/plantuml)
 author: requarks.io
 author: requarks.io
 icon: mdi-cloud-download-outline
 icon: mdi-cloud-download-outline
 enabledDefault: false
 enabledDefault: false
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-mediaplayers/definition.yml

@@ -4,5 +4,5 @@ description: Embed players such as Youtube, Vimeo, Soundcloud, etc.
 author: requarks.io
 author: requarks.io
 icon: mdi-video
 icon: mdi-video
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-mermaid/definition.yml

@@ -4,5 +4,5 @@ description: Generate flowcharts from Mermaid syntax
 author: requarks.io
 author: requarks.io
 icon: mdi-arrow-decision-outline
 icon: mdi-arrow-decision-outline
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-security/definition.yml

@@ -4,7 +4,7 @@ description: Filter and strips potentially dangerous content
 author: requarks.io
 author: requarks.io
 icon: mdi-fire
 icon: mdi-fire
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 step: post
 step: post
 order: 99999
 order: 99999
 props:
 props:

+ 1 - 1
server/modules/rendering/html-tabset/definition.yml

@@ -4,5 +4,5 @@ description: Transform headers into tabs
 author: requarks.io
 author: requarks.io
 icon: mdi-tab
 icon: mdi-tab
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/html-twemoji/definition.yml

@@ -4,7 +4,7 @@ description: Apply Twitter Emojis to all Unicode emojis
 author: requarks.io
 author: requarks.io
 icon: mdi-emoticon-happy-outline
 icon: mdi-emoticon-happy-outline
 enabledDefault: true
 enabledDefault: true
-dependsOn: htmlCore
+dependsOn: html-core
 step: post
 step: post
 order: 10
 order: 10
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/markdown-abbr/definition.yml

@@ -4,5 +4,5 @@ description: Parse abbreviations into abbr tags
 author: requarks.io
 author: requarks.io
 icon: mdi-contain-start
 icon: mdi-contain-start
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/markdown-core/definition.yml

@@ -1,4 +1,4 @@
-key: markdownCore
+key: markdown-core
 title: Core
 title: Core
 description: Basic Markdown Parser
 description: Basic Markdown Parser
 author: requarks.io
 author: requarks.io

+ 1 - 1
server/modules/rendering/markdown-core/renderer.js

@@ -44,7 +44,7 @@ module.exports = {
     })
     })
 
 
     for (let child of this.children) {
     for (let child of this.children) {
-      const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
+      const renderer = require(`../${child.key}/renderer.js`)
       await renderer.init(mkdown, child.config)
       await renderer.init(mkdown, child.config)
     }
     }
 
 

+ 1 - 1
server/modules/rendering/markdown-emoji/definition.yml

@@ -4,5 +4,5 @@ description: Convert tags to emojis
 author: requarks.io
 author: requarks.io
 icon: mdi-sticker-emoji
 icon: mdi-sticker-emoji
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/markdown-expandtabs/definition.yml

@@ -4,7 +4,7 @@ description: Replace tabs with spaces in code blocks
 author: requarks.io
 author: requarks.io
 icon: mdi-arrow-expand-horizontal
 icon: mdi-arrow-expand-horizontal
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   tabWidth:
   tabWidth:
     type: Number
     type: Number

+ 1 - 1
server/modules/rendering/markdown-footnotes/definition.yml

@@ -4,5 +4,5 @@ description: Parse footnotes references
 author: requarks.io
 author: requarks.io
 icon: mdi-page-layout-footer
 icon: mdi-page-layout-footer
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/markdown-imsize/definition.yml

@@ -4,5 +4,5 @@ description: Adds dimensions attributes to images
 author: requarks.io
 author: requarks.io
 icon: mdi-image-size-select-large
 icon: mdi-image-size-select-large
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props: {}
 props: {}

+ 1 - 1
server/modules/rendering/markdown-katex/definition.yml

@@ -4,7 +4,7 @@ description: LaTeX Math + Chemical Expression Typesetting Renderer
 author: requarks.io
 author: requarks.io
 icon: mdi-math-integral
 icon: mdi-math-integral
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   useInline:
   useInline:
     type: Boolean
     type: Boolean

+ 1 - 1
server/modules/rendering/markdown-kroki/definition.yml

@@ -4,7 +4,7 @@ description: Kroki Diagrams Parser
 author: rlanyi (based on PlantUML renderer)
 author: rlanyi (based on PlantUML renderer)
 icon: mdi-sitemap
 icon: mdi-sitemap
 enabledDefault: false
 enabledDefault: false
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   server:
   server:
     type: String
     type: String

+ 1 - 1
server/modules/rendering/markdown-mathjax/definition.yml

@@ -4,7 +4,7 @@ description: LaTeX Math + Chemical Expression Typesetting Renderer
 author: requarks.io
 author: requarks.io
 icon: mdi-math-integral
 icon: mdi-math-integral
 enabledDefault: false
 enabledDefault: false
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   useInline:
   useInline:
     type: Boolean
     type: Boolean

+ 1 - 1
server/modules/rendering/markdown-multi-table/definition.yml

@@ -4,7 +4,7 @@ description: Add MultiMarkdown table support
 author: requarks.io
 author: requarks.io
 icon: mdi-table
 icon: mdi-table
 enabledDefault: false
 enabledDefault: false
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   multilineEnabled:
   multilineEnabled:
     type: Boolean
     type: Boolean

+ 1 - 1
server/modules/rendering/markdown-plantuml/definition.yml

@@ -4,7 +4,7 @@ description: PlantUML Markdown Parser
 author: ethanmdavidson
 author: ethanmdavidson
 icon: mdi-sitemap
 icon: mdi-sitemap
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   server:
   server:
     type: String
     type: String

+ 1 - 1
server/modules/rendering/markdown-supsub/definition.yml

@@ -4,7 +4,7 @@ description: Parse subscript and superscript tags
 author: requarks.io
 author: requarks.io
 icon: mdi-format-superscript
 icon: mdi-format-superscript
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props:
 props:
   subEnabled:
   subEnabled:
     type: Boolean
     type: Boolean

+ 1 - 1
server/modules/rendering/markdown-tasklists/definition.yml

@@ -4,5 +4,5 @@ description: Parse task lists to checkboxes
 author: requarks.io
 author: requarks.io
 icon: mdi-format-list-checks
 icon: mdi-format-list-checks
 enabledDefault: true
 enabledDefault: true
-dependsOn: markdownCore
+dependsOn: markdown-core
 props: {}
 props: {}

+ 92 - 0
server/tasks/workers/render-page.js

@@ -0,0 +1,92 @@
+const _ = require('lodash')
+const cheerio = require('cheerio')
+
+module.exports = async ({ payload }) => {
+  WIKI.logger.info(`Rendering page ${payload.id}...`)
+
+  try {
+    await WIKI.ensureDb()
+
+    const page = await WIKI.db.pages.getPageFromDb(payload.id)
+    if (!page) {
+      throw new Error('Invalid Page Id')
+    }
+
+    const site = await WIKI.db.sites.query().findById(page.siteId)
+
+    await WIKI.db.renderers.fetchDefinitions()
+
+    const pipeline = await WIKI.db.renderers.getRenderingPipeline(page.contentType)
+
+    let output = page.content
+
+    if (_.isEmpty(page.content)) {
+      WIKI.logger.warn(`Failed to render page ID ${payload.id} because content was empty: [ FAILED ]`)
+    }
+
+    for (let core of pipeline) {
+      const renderer = require(`../../modules/rendering/${core.key}/renderer.js`)
+      output = await renderer.render.call({
+        config: core.config,
+        children: core.children,
+        page,
+        site,
+        input: output
+      })
+    }
+
+    // Parse TOC
+    const $ = cheerio.load(output)
+    let isStrict = $('h1').length > 0 // <- Allows for documents using H2 as top level
+    let toc = { root: [] }
+
+    $('h1,h2,h3,h4,h5,h6').each((idx, el) => {
+      const depth = _.toSafeInteger(el.name.substring(1)) - (isStrict ? 1 : 2)
+      let leafPathError = false
+
+      const leafPath = _.reduce(_.times(depth), (curPath, curIdx) => {
+        if (_.has(toc, curPath)) {
+          const lastLeafIdx = _.get(toc, curPath).length - 1
+          if (lastLeafIdx >= 0) {
+            curPath = `${curPath}[${lastLeafIdx}].children`
+          } else {
+            leafPathError = true
+          }
+        }
+        return curPath
+      }, 'root')
+
+      if (leafPathError) { return }
+
+      const leafSlug = $('.toc-anchor', el).first().attr('href')
+      $('.toc-anchor', el).remove()
+
+      _.get(toc, leafPath).push({
+        title: _.trim($(el).text()),
+        anchor: leafSlug,
+        children: []
+      })
+    })
+
+    // Save to DB
+    await WIKI.db.pages.query()
+      .patch({
+        render: output,
+        toc: JSON.stringify(toc.root)
+      })
+      .where('id', payload.id)
+
+    // Save to cache
+    // await WIKI.db.pages.savePageToCache({
+    //   ...page,
+    //   render: output,
+    //   toc: JSON.stringify(toc.root)
+    // })
+
+    WIKI.logger.info(`Rendered page ${payload.id}: [ COMPLETED ]`)
+  } catch (err) {
+    WIKI.logger.error(`Rendering page ${payload.id}: [ FAILED ]`)
+    WIKI.logger.error(err.message)
+    throw err
+  }
+}

+ 0 - 13
server/views/page.pug

@@ -5,8 +5,6 @@ block head
     style(type='text/css')!= injectCode.css
     style(type='text/css')!= injectCode.css
   if injectCode.head
   if injectCode.head
     != injectCode.head
     != injectCode.head
-  if config.features.featurePageComments
-    != comments.head
 
 
 block body
 block body
   #root
   #root
@@ -21,20 +19,9 @@ block body
       author-name=page.authorName
       author-name=page.authorName
       :author-id=page.authorId
       :author-id=page.authorId
       editor=page.editorKey
       editor=page.editorKey
-      :is-published=page.isPublished.toString()
-      toc=Buffer.from(page.toc).toString('base64')
       :page-id=page.id
       :page-id=page.id
-      sidebar=Buffer.from(JSON.stringify(sidebar)).toString('base64')
-      nav-mode=config.nav.mode
-      comments-enabled=config.features.featurePageComments
-      effective-permissions=Buffer.from(JSON.stringify(effectivePermissions)).toString('base64')
-      comments-external=comments.codeTemplate
       )
       )
       template(slot='contents')
       template(slot='contents')
         div!= page.render
         div!= page.render
-      template(slot='comments')
-        div!= comments.main
   if injectCode.body
   if injectCode.body
     != injectCode.body
     != injectCode.body
-  if config.features.featurePageComments
-    != comments.body

+ 17 - 1
server/worker.js

@@ -12,7 +12,23 @@ let WIKI = {
   INSTANCE_ID: 'worker',
   INSTANCE_ID: 'worker',
   SERVERPATH: path.join(process.cwd(), 'server'),
   SERVERPATH: path.join(process.cwd(), 'server'),
   Error: require('./helpers/error'),
   Error: require('./helpers/error'),
-  configSvc: require('./core/config')
+  configSvc: require('./core/config'),
+  ensureDb: async () => {
+    if (WIKI.db) { return true }
+
+    WIKI.db = require('./core/db').init(true)
+
+    try {
+      await WIKI.configSvc.loadFromDb()
+      await WIKI.configSvc.applyFlags()
+    } catch (err) {
+      WIKI.logger.error('Database Initialization Error: ' + err.message)
+      if (WIKI.IS_DEBUG) {
+        WIKI.logger.error(err)
+      }
+      process.exit(1)
+    }
+  }
 }
 }
 global.WIKI = WIKI
 global.WIKI = WIKI
 
 

+ 43 - 28
ux/src/components/PageTags.vue

@@ -1,21 +1,21 @@
 <template lang="pug">
 <template lang="pug">
 .q-gutter-xs
 .q-gutter-xs
-  template(v-if='tags && tags.length > 0')
+  template(v-if='pageStore.tags && pageStore.tags.length > 0')
     q-chip(
     q-chip(
       square
       square
       color='secondary'
       color='secondary'
       text-color='white'
       text-color='white'
       dense
       dense
       clickable
       clickable
-      :removable='edit'
+      :removable='props.edit'
       @remove='removeTag(tag)'
       @remove='removeTag(tag)'
-      v-for='tag of tags'
+      v-for='tag of pageStore.tags'
       :key='`tag-` + tag'
       :key='`tag-` + tag'
       )
       )
       q-icon.q-mr-xs(name='las la-tag', size='14px')
       q-icon.q-mr-xs(name='las la-tag', size='14px')
       span.text-caption {{tag}}
       span.text-caption {{tag}}
     q-chip(
     q-chip(
-      v-if='!edit && tags.length > 1'
+      v-if='!props.edit && pageStore.tags.length > 1'
       square
       square
       color='secondary'
       color='secondary'
       text-color='white'
       text-color='white'
@@ -24,36 +24,51 @@
       )
       )
       q-icon(name='las la-tags', size='14px')
       q-icon(name='las la-tags', size='14px')
   q-input.q-mt-md(
   q-input.q-mt-md(
-    v-if='edit'
+    v-if='props.edit'
     outlined
     outlined
-    v-model='newTag'
+    v-model='state.newTag'
     dense
     dense
     placeholder='Add new tag...'
     placeholder='Add new tag...'
   )
   )
 </template>
 </template>
 
 
-<script>
-import { sync } from 'vuex-pathify'
-
-export default {
-  props: {
-    edit: {
-      type: Boolean,
-      default: false
-    }
-  },
-  data () {
-    return {
-      newTag: ''
-    }
-  },
-  computed: {
-    tags: sync('page/tags', false)
-  },
-  methods: {
-    removeTag (tag) {
-      this.tags = this.tags.filter(t => t !== tag)
-    }
+<script setup>
+import { useQuasar } from 'quasar'
+import { reactive } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+import { usePageStore } from 'src/stores/page'
+
+// PROPS
+
+const props = defineProps({
+  edit: {
+    type: Boolean,
+    default: false
   }
   }
+})
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const pageStore = usePageStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  newTag: ''
+})
+
+// METHODS
+
+function removeTag (tag) {
+  pageStore.tags = pageStore.tags.filter(t => t !== tag)
 }
 }
 </script>
 </script>

+ 107 - 86
ux/src/components/SocialSharingMenu.vue

@@ -11,125 +11,146 @@ q-menu(
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='las la-clipboard', size='sm')
         q-icon(color='grey', name='las la-clipboard', size='sm')
       q-item-section.q-pr-md Copy URL
       q-item-section.q-pr-md Copy URL
-    q-item(clickable, tag='a', :href='`mailto:?subject=` + encodeURIComponent(title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(description)', target='_blank')
+    q-item(clickable, tag='a', :href='`mailto:?subject=` + encodeURIComponent(props.title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(props.description)', target='_blank')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='las la-envelope', size='sm')
         q-icon(color='grey', name='las la-envelope', size='sm')
       q-item-section.q-pr-md Email
       q-item-section.q-pr-md Email
-    q-item(clickable, @click='openSocialPop(`https://www.facebook.com/sharer/sharer.php?u=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title) + `&description=` + encodeURIComponent(description))')
+    q-item(clickable, @click='openSocialPop(`https://www.facebook.com/sharer/sharer.php?u=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title) + `&description=` + encodeURIComponent(props.description))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-facebook', size='sm')
         q-icon(color='grey', name='lab la-facebook', size='sm')
       q-item-section.q-pr-md Facebook
       q-item-section.q-pr-md Facebook
-    q-item(clickable, @click='openSocialPop(`https://www.linkedin.com/shareArticle?mini=true&url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title) + `&summary=` + encodeURIComponent(description))')
+    q-item(clickable, @click='openSocialPop(`https://www.linkedin.com/shareArticle?mini=true&url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title) + `&summary=` + encodeURIComponent(props.description))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-linkedin', size='sm')
         q-icon(color='grey', name='lab la-linkedin', size='sm')
       q-item-section.q-pr-md LinkedIn
       q-item-section.q-pr-md LinkedIn
-    q-item(clickable, @click='openSocialPop(`https://www.reddit.com/submit?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title))')
+    q-item(clickable, @click='openSocialPop(`https://www.reddit.com/submit?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-reddit', size='sm')
         q-icon(color='grey', name='lab la-reddit', size='sm')
       q-item-section.q-pr-md Reddit
       q-item-section.q-pr-md Reddit
-    q-item(clickable, @click='openSocialPop(`https://t.me/share/url?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(title))')
+    q-item(clickable, @click='openSocialPop(`https://t.me/share/url?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(props.title))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-telegram', size='sm')
         q-icon(color='grey', name='lab la-telegram', size='sm')
       q-item-section.q-pr-md Telegram
       q-item-section.q-pr-md Telegram
-    q-item(clickable, @click='openSocialPop(`https://twitter.com/intent/tweet?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(title))')
+    q-item(clickable, @click='openSocialPop(`https://twitter.com/intent/tweet?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(props.title))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-twitter', size='sm')
         q-icon(color='grey', name='lab la-twitter', size='sm')
       q-item-section.q-pr-md Twitter
       q-item-section.q-pr-md Twitter
-    q-item(clickable, :href='`viber://forward?text=` + encodeURIComponent(urlFormatted) + ` ` + encodeURIComponent(description)')
+    q-item(clickable, :href='`viber://forward?text=` + encodeURIComponent(urlFormatted) + ` ` + encodeURIComponent(props.description)')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-viber', size='sm')
         q-icon(color='grey', name='lab la-viber', size='sm')
       q-item-section.q-pr-md Viber
       q-item-section.q-pr-md Viber
-    q-item(clickable, @click='openSocialPop(`http://service.weibo.com/share/share.php?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title))')
+    q-item(clickable, @click='openSocialPop(`http://service.weibo.com/share/share.php?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-weibo', size='sm')
         q-icon(color='grey', name='lab la-weibo', size='sm')
       q-item-section.q-pr-md Weibo
       q-item-section.q-pr-md Weibo
-    q-item(clickable, @click='openSocialPop(`https://api.whatsapp.com/send?text=` + encodeURIComponent(title) + `%0D%0A` + encodeURIComponent(urlFormatted))')
+    q-item(clickable, @click='openSocialPop(`https://api.whatsapp.com/send?text=` + encodeURIComponent(props.title) + `%0D%0A` + encodeURIComponent(urlFormatted))')
       q-item-section.items-center(avatar)
       q-item-section.items-center(avatar)
         q-icon(color='grey', name='lab la-whatsapp', size='sm')
         q-icon(color='grey', name='lab la-whatsapp', size='sm')
       q-item-section.q-pr-md Whatsapp
       q-item-section.q-pr-md Whatsapp
 </template>
 </template>
 
 
-<script>
+<script setup>
 import ClipboardJS from 'clipboard'
 import ClipboardJS from 'clipboard'
+import { useQuasar } from 'quasar'
+import { computed, onMounted, reactive, ref } from 'vue'
+import { useI18n } from 'vue-i18n'
 
 
-export default {
-  props: {
-    url: {
-      type: String,
-      default: null
-    },
-    title: {
-      type: String,
-      default: 'Untitled Page'
-    },
-    description: {
-      type: String,
-      default: ''
-    }
-  },
-  data () {
-    return {
-      width: 626,
-      height: 436,
-      left: 0,
-      top: 0,
-      clip: null
-    }
-  },
-  computed: {
-    urlFormatted () {
-      if (!import.meta.env.SSR) {
-        return this.url ? this.url : window.location.href
-      } else {
-        return ''
-      }
-    }
+// PROPS
+
+const props = defineProps({
+  url: {
+    type: String,
+    default: null
   },
   },
-  methods: {
-    openSocialPop (url) {
-      const popupWindow = window.open(
-        url,
-        'sharer',
-        `status=no,height=${this.height},width=${this.width},resizable=yes,left=${this.left},top=${this.top},screenX=${this.left},screenY=${this.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
-      )
-
-      popupWindow.focus()
-    },
-    menuShown (ev) {
-      this.clip = new ClipboardJS(this.$refs.copyUrlButton.$el, {
-        text: () => { return this.urlFormatted }
-      })
-
-      this.clip.on('success', () => {
-        this.$q.notify({
-          message: 'URL copied successfully',
-          icon: 'las la-clipboard'
-        })
-      })
-      this.clip.on('error', () => {
-        this.$q.notify({
-          type: 'negative',
-          message: 'Failed to copy to clipboard'
-        })
-      })
-    },
-    menuHidden (ev) {
-      this.clip.destroy()
-    }
+  title: {
+    type: String,
+    default: 'Untitled Page'
   },
   },
-  mounted () {
-    /**
-     * Center the popup on dual screens
-     * http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
-     */
-    const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
-    const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
-
-    const width = window.innerWidth ? window.innerWidth : (document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width)
-    const height = window.innerHeight ? window.innerHeight : (document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height)
-
-    this.left = ((width / 2) - (this.width / 2)) + dualScreenLeft
-    this.top = ((height / 2) - (this.height / 2)) + dualScreenTop
+  description: {
+    type: String,
+    default: ''
+  }
+})
+
+// QUASAR
+
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  width: 626,
+  height: 436,
+  left: 0,
+  top: 0,
+  clip: null
+})
+let clip = null
+
+const copyUrlButton = ref(null)
+
+// COMPUTED
+
+const urlFormatted = computed(() => {
+  if (!import.meta.env.SSR) {
+    return props.url ? props.url : window.location.href
+  } else {
+    return ''
   }
   }
+})
+// METHODS
+
+function openSocialPop (url) {
+  const popupWindow = window.open(
+    url,
+    'sharer',
+    `status=no,height=${state.height},width=${state.width},resizable=yes,left=${state.left},top=${state.top},screenX=${state.left},screenY=${state.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
+  )
+
+  popupWindow.focus()
+}
+
+function menuShown (ev) {
+  clip = new ClipboardJS(copyUrlButton.value.$el, {
+    text: () => { return urlFormatted.value }
+  })
+
+  clip.on('success', () => {
+    $q.notify({
+      message: 'URL copied successfully',
+      icon: 'las la-clipboard'
+    })
+  })
+  clip.on('error', () => {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to copy to clipboard'
+    })
+  })
 }
 }
+
+function menuHidden (ev) {
+  clip.destroy()
+}
+
+// MOUNTED
+
+onMounted(() => {
+  /**
+   * Center the popup on dual screens
+   * http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
+   */
+  const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
+  const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
+
+  const width = window.innerWidth ? window.innerWidth : (document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width)
+  const height = window.innerHeight ? window.innerHeight : (document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height)
+
+  state.left = ((width / 2) - (state.width / 2)) + dualScreenLeft
+  state.top = ((height / 2) - (state.height / 2)) + dualScreenTop
+})
 </script>
 </script>

+ 5 - 1
ux/src/i18n/locales/en.json

@@ -1540,5 +1540,9 @@
   "admin.instances.lastSeen": "Last Seen",
   "admin.instances.lastSeen": "Last Seen",
   "admin.instances.firstSeen": "First Seen",
   "admin.instances.firstSeen": "First Seen",
   "admin.instances.activeListeners": "Active Listeners",
   "admin.instances.activeListeners": "Active Listeners",
-  "admin.instances.activeConnections": "Active Connections"
+  "admin.instances.activeConnections": "Active Connections",
+  "admin.scheduler.cancelJob": "Cancel Job",
+  "admin.scheduler.cancelJobSuccess": "Job cancelled successfully.",
+  "admin.scheduler.retryJob": "Retry Job",
+  "admin.scheduler.retryJobSuccess": "Job has been rescheduled and will execute shortly."
 }
 }

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

@@ -3,7 +3,7 @@ q-layout.admin(view='hHh Lpr lff')
   q-header.bg-black.text-white
   q-header.bg-black.text-white
     .row.no-wrap
     .row.no-wrap
       q-toolbar(style='height: 64px;', dark)
       q-toolbar(style='height: 64px;', dark)
-        q-btn(dense, flat, href='/')
+        q-btn(dense, flat, to='/')
           q-avatar(size='34px', square)
           q-avatar(size='34px', square)
             img(src='/_assets/logo-wikijs.svg')
             img(src='/_assets/logo-wikijs.svg')
         q-toolbar-title.text-h6 Wiki.js
         q-toolbar-title.text-h6 Wiki.js
@@ -102,10 +102,6 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section(avatar)
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
             q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
           q-item-section {{ t('admin.navigation.title') }}
           q-item-section {{ t('admin.navigation.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/rendering`', v-ripple, active-class='bg-primary text-white', disabled)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
-          q-item-section {{ t('admin.rendering.title') }}
         q-item(:to='`/_admin/` + adminStore.currentSiteId + `/storage`', v-ripple, active-class='bg-primary text-white')
         q-item(:to='`/_admin/` + adminStore.currentSiteId + `/storage`', v-ripple, active-class='bg-primary text-white')
           q-item-section(avatar)
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-ssd.svg')
             q-icon(name='img:/_assets/icons/fluent-ssd.svg')
@@ -156,6 +152,10 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section {{ t('admin.mail.title') }}
           q-item-section {{ t('admin.mail.title') }}
           q-item-section(side)
           q-item-section(side)
             status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`')
             status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`')
+        q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white', disabled)
+          q-item-section(avatar)
+            q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
+          q-item-section {{ t('admin.rendering.title') }}
         q-item(to='/_admin/scheduler', v-ripple, active-class='bg-primary text-white')
         q-item(to='/_admin/scheduler', v-ripple, active-class='bg-primary text-white')
           q-item-section(avatar)
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-bot.svg')
             q-icon(name='img:/_assets/icons/fluent-bot.svg')

+ 56 - 38
ux/src/layouts/MainLayout.vue

@@ -2,7 +2,7 @@
 q-layout(view='hHh Lpr lff')
 q-layout(view='hHh Lpr lff')
   header-nav
   header-nav
   q-drawer.bg-sidebar(
   q-drawer.bg-sidebar(
-    v-model='showSideNav'
+    v-model='siteStore.showSideNav'
     show-if-above
     show-if-above
     :width='255'
     :width='255'
     )
     )
@@ -81,46 +81,64 @@ q-layout(view='hHh Lpr lff')
       span(style='font-size: 11px;') &copy; Cyberdyne Systems Corp. 2020 | Powered by #[strong Wiki.js]
       span(style='font-size: 11px;') &copy; Cyberdyne Systems Corp. 2020 | Powered by #[strong Wiki.js]
 </template>
 </template>
 
 
-<script>
-import { get, sync } from 'vuex-pathify'
-import { setCssVar } from 'quasar'
+<script setup>
+import { useMeta, useQuasar, setCssVar } from 'quasar'
+import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+
+import { useSiteStore } from '../stores/site'
+
+// COMPONENTS
+
+import AccountMenu from '../components/AccountMenu.vue'
 import HeaderNav from '../components/HeaderNav.vue'
 import HeaderNav from '../components/HeaderNav.vue'
 
 
-export default {
-  name: 'MainLayout',
-  components: {
-    HeaderNav
-  },
-  data () {
-    return {
-      leftDrawerOpen: true,
-      search: '',
-      thumbStyle: {
-        right: '2px',
-        borderRadius: '5px',
-        backgroundColor: '#FFF',
-        width: '5px',
-        opacity: 0.5
-      },
-      barStyle: {
-        backgroundColor: '#000',
-        width: '9px',
-        opacity: 0.1
-      }
-    }
-  },
-  computed: {
-    showSideNav: sync('site/showSideNav', false),
-    isSyncing: get('isLoading', false)
-  },
-  created () {
-    setCssVar('primary', this.$store.get('site/theme@colorPrimary'))
-    setCssVar('secondary', this.$store.get('site/theme@colorSecondary'))
-    setCssVar('accent', this.$store.get('site/theme@colorAccent'))
-    setCssVar('header', this.$store.get('site/theme@colorHeader'))
-    setCssVar('sidebar', this.$store.get('site/theme@colorSidebar'))
-  }
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const siteStore = useSiteStore()
+
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  titleTemplate: title => `${title} - ${siteStore.title}`
+})
+
+// DATA
+
+const leftDrawerOpen = ref(true)
+const search = ref('')
+const user = reactive({
+  name: 'John Doe',
+  email: 'test@example.com',
+  picture: null
+})
+const thumbStyle = {
+  right: '2px',
+  borderRadius: '5px',
+  backgroundColor: '#FFF',
+  width: '5px',
+  opacity: 0.5
 }
 }
+const barStyle = {
+  backgroundColor: '#000',
+  width: '9px',
+  opacity: 0.1
+}
+
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">

+ 111 - 3
ux/src/pages/AdminScheduler.vue

@@ -132,7 +132,7 @@ q-page.admin-terminal
               div: small.text-grey {{humanizeDate(props.row.waitUntil)}}
               div: small.text-grey {{humanizeDate(props.row.waitUntil)}}
           template(v-slot:body-cell-retries='props')
           template(v-slot:body-cell-retries='props')
             q-td(:props='props')
             q-td(:props='props')
-              span #[strong {{props.value + 1}}] #[span.text-grey / {{props.row.maxRetries}}]
+              span #[strong {{props.value + 1}}] #[span.text-grey / {{props.row.maxRetries + 1}}]
           template(v-slot:body-cell-useworker='props')
           template(v-slot:body-cell-useworker='props')
             q-td(:props='props')
             q-td(:props='props')
               template(v-if='props.value')
               template(v-if='props.value')
@@ -148,6 +148,15 @@ q-page.admin-terminal
                 i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
                 i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
                   template(#instance)
                   template(#instance)
                     strong {{props.row.createdBy}}
                     strong {{props.row.createdBy}}
+          template(v-slot:body-cell-cancel='props')
+            q-td(:props='props')
+              q-btn.acrylic-btn.q-px-sm(
+                flat
+                icon='las la-window-close'
+                color='negative'
+                @click='cancelJob(props.row.id)'
+                )
+                q-tooltip(anchor='center left', self='center right') {{ t('admin.scheduler.cancelJob') }}
     template(v-else)
     template(v-else)
       q-card.rounded-borders(
       q-card.rounded-borders(
         v-if='state.jobs.length < 1'
         v-if='state.jobs.length < 1'
@@ -221,7 +230,7 @@ q-page.admin-terminal
                 div: small {{ props.row.lastErrorMessage }}
                 div: small {{ props.row.lastErrorMessage }}
           template(v-slot:body-cell-attempt='props')
           template(v-slot:body-cell-attempt='props')
             q-td(:props='props')
             q-td(:props='props')
-              span #[strong {{props.value}}] #[span.text-grey / {{props.row.maxRetries}}]
+              span #[strong {{props.value}}] #[span.text-grey / {{props.row.maxRetries + 1}}]
           template(v-slot:body-cell-useworker='props')
           template(v-slot:body-cell-useworker='props')
             q-td(:props='props')
             q-td(:props='props')
               template(v-if='props.value')
               template(v-if='props.value')
@@ -238,6 +247,17 @@ q-page.admin-terminal
                 i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
                 i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
                   template(#instance)
                   template(#instance)
                     strong {{props.row.executedBy}}
                     strong {{props.row.executedBy}}
+          template(v-slot:body-cell-actions='props')
+            q-td(:props='props')
+              q-btn.acrylic-btn.q-px-sm(
+                v-if='props.row.state !== `active`'
+                flat
+                icon='las la-undo-alt'
+                color='orange'
+                @click='retryJob(props.row.id)'
+                :disable='props.row.state === `interrupted` || props.row.state === `failed` && props.row.attempt < props.row.maxRetries'
+                )
+                q-tooltip(anchor='center left', self='center right') {{ t('admin.scheduler.retryJob') }}
 
 
 </template>
 </template>
 
 
@@ -271,7 +291,7 @@ useMeta({
 // DATA
 // DATA
 
 
 const state = reactive({
 const state = reactive({
-  displayMode: 'completed',
+  displayMode: 'upcoming',
   scheduledJobs: [],
   scheduledJobs: [],
   upcomingJobs: [],
   upcomingJobs: [],
   jobs: [],
   jobs: [],
@@ -369,6 +389,13 @@ const upcomingJobsHeaders = [
     name: 'date',
     name: 'date',
     sortable: true,
     sortable: true,
     format: v => DateTime.fromISO(v).toRelative()
     format: v => DateTime.fromISO(v).toRelative()
+  },
+  {
+    align: 'center',
+    field: 'id',
+    name: 'cancel',
+    sortable: false,
+    style: 'width: 15px;'
   }
   }
 ]
 ]
 
 
@@ -415,6 +442,13 @@ const jobsHeaders = [
     name: 'date',
     name: 'date',
     sortable: true,
     sortable: true,
     format: v => DateTime.fromISO(v).toRelative()
     format: v => DateTime.fromISO(v).toRelative()
+  },
+  {
+    align: 'center',
+    field: 'id',
+    name: 'actions',
+    sortable: false,
+    style: 'width: 15px;'
   }
   }
 ]
 ]
 
 
@@ -524,6 +558,80 @@ async function load () {
   state.loading--
   state.loading--
 }
 }
 
 
+async function cancelJob (jobId) {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation cancelJob ($id: UUID!) {
+          cancelJob(id: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: jobId
+      }
+    })
+    if (resp?.data?.cancelJob?.operation?.succeeded) {
+      this.load()
+      $q.notify({
+        type: 'positive',
+        message: t('admin.scheduler.cancelJobSuccess')
+      })
+    } else {
+      throw new Error(resp?.data?.cancelJob?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to cancel job.',
+      caption: err.message
+    })
+  }
+  state.loading--
+}
+
+async function retryJob (jobId) {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation retryJob ($id: UUID!) {
+          retryJob(id: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: jobId
+      }
+    })
+    if (resp?.data?.retryJob?.operation?.succeeded) {
+      this.load()
+      $q.notify({
+        type: 'positive',
+        message: t('admin.scheduler.retryJobSuccess')
+      })
+    } else {
+      throw new Error(resp?.data?.retryJob?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to retry the job.',
+      caption: err.message
+    })
+  }
+  state.loading--
+}
+
 // MOUNTED
 // MOUNTED
 
 
 onMounted(() => {
 onMounted(() => {

+ 207 - 256
ux/src/pages/Index.vue

@@ -13,97 +13,33 @@ q-page.column
         q-breadcrumbs-el(icon='las la-home', to='/', aria-label='Home')
         q-breadcrumbs-el(icon='las la-home', to='/', aria-label='Home')
           q-tooltip Home
           q-tooltip Home
         q-breadcrumbs-el(
         q-breadcrumbs-el(
-          v-for='brd of breadcrumbs'
+          v-for='brd of pageStore.breadcrumbs'
           :key='brd.id'
           :key='brd.id'
           :icon='brd.icon'
           :icon='brd.icon'
           :label='brd.title'
           :label='brd.title'
           :aria-label='brd.title'
           :aria-label='brd.title'
-          :to='$pageHelpers.getFullPath(brd)'
-          )
-        q-breadcrumbs-el(
-          v-if='editCreateMode'
-          :icon='pageIcon'
-          :label='title || `Untitled Page`'
-          :aria-label='title || `Untitled Page`'
+          :to='getFullPath(brd)'
           )
           )
     .col-auto.flex.items-center.justify-end
     .col-auto.flex.items-center.justify-end
-      template(v-if='!isPublished')
+      template(v-if='!pageStore.isPublished')
         .text-caption.text-accent: strong Unpublished
         .text-caption.text-accent: strong Unpublished
         q-separator.q-mx-sm(vertical)
         q-separator.q-mx-sm(vertical)
-      .text-caption.text-grey-6(v-if='editCreateMode') New Page
-      .text-caption.text-grey-6(v-if='!editCreateMode') Last modified on #[strong September 5th, 2020]
+      .text-caption.text-grey-6 Last modified on #[strong September 5th, 2020]
   .page-header.row
   .page-header.row
     //- PAGE ICON
     //- PAGE ICON
-    .col-auto.q-pl-md.flex.items-center(v-if='editMode')
-      q-btn.rounded-borders(
-        padding='none'
-        size='37px'
-        :icon='pageIcon'
-        color='primary'
-        flat
-        )
-        q-menu(content-class='shadow-7')
-          icon-picker-dialog(v-model='pageIcon')
-    .col-auto.q-pl-md.flex.items-center(v-else)
+    .col-auto.q-pl-md.flex.items-center
       q-icon.rounded-borders(
       q-icon.rounded-borders(
-        :name='pageIcon'
+        :name='pageStore.icon'
         size='64px'
         size='64px'
         color='primary'
         color='primary'
       )
       )
     //- PAGE HEADER
     //- PAGE HEADER
-    .col.q-pa-md(v-if='editMode')
-      q-input.no-height(
-        borderless
-        v-model='title'
-        input-class='text-h4 text-grey-9'
-        input-style='padding: 0;'
-        placeholder='Untitled Page'
-        hide-hint
-        )
-      q-input.no-height(
-        borderless
-        v-model='description'
-        input-class='text-subtitle2 text-grey-7'
-        input-style='padding: 0;'
-        placeholder='Enter a short description'
-        hide-hint
-        )
-    .col.q-pa-md(v-else)
-      .text-h4.page-header-title {{title}}
-      .text-subtitle2.page-header-subtitle {{description}}
+    .col.q-pa-md
+      .text-h4.page-header-title {{pageStore.title}}
+      .text-subtitle2.page-header-subtitle {{pageStore.description}}
 
 
     //- PAGE ACTIONS
     //- PAGE ACTIONS
-    .col-auto.q-pa-md.flex.items-center.justify-end(v-if='editMode')
-      q-btn.q-mr-sm.acrylic-btn(
-        flat
-        icon='las la-times'
-        color='grey-7'
-        label='Discard'
-        aria-label='Discard'
-        no-caps
-        @click='mode = `view`'
-      )
-      q-btn(
-        v-if='editorMode === `edit`'
-        unelevated
-        icon='las la-check'
-        color='secondary'
-        label='Save'
-        aria-label='Save'
-        no-caps
-        @click='mode = `view`'
-      )
-      q-btn(
-        v-else
-        unelevated
-        icon='las la-check'
-        color='secondary'
-        label='Create'
-        aria-label='Create'
-        no-caps
-        @click='mode = `view`'
-      )
-    .col-auto.q-pa-md.flex.items-center.justify-end(v-else)
+    .col-auto.q-pa-md.flex.items-center.justify-end
       q-btn.q-mr-md(
       q-btn.q-mr-md(
         flat
         flat
         dense
         dense
@@ -144,23 +80,18 @@ q-page.column
         label='Edit'
         label='Edit'
         aria-label='Edit'
         aria-label='Edit'
         no-caps
         no-caps
-        @click='mode = `edit`'
+        :href='editUrl'
       )
       )
   .page-container.row.no-wrap.items-stretch(style='flex: 1 1 100%;')
   .page-container.row.no-wrap.items-stretch(style='flex: 1 1 100%;')
     .col(style='order: 1;')
     .col(style='order: 1;')
-      q-no-ssr(v-if='editMode')
-        component(:is='editorComponent')
-        //- editor-wysiwyg
-        //- editor-markdown
       q-scroll-area(
       q-scroll-area(
         :thumb-style='thumbStyle'
         :thumb-style='thumbStyle'
         :bar-style='barStyle'
         :bar-style='barStyle'
         style='height: 100%;'
         style='height: 100%;'
-        v-else
         )
         )
         .q-pa-md
         .q-pa-md
-          div(v-html='render')
-          template(v-if='relations && relations.length > 0')
+          div(v-html='pageStore.render')
+          template(v-if='pageStore.relations && pageStore.relations.length > 0')
             q-separator.q-my-lg
             q-separator.q-my-lg
             .row.align-center
             .row.align-center
               .col.text-left(v-if='relationsLeft.length > 0')
               .col.text-left(v-if='relationsLeft.length > 0')
@@ -204,24 +135,24 @@ q-page.column
       v-if='showSidebar'
       v-if='showSidebar'
       style='order: 2;'
       style='order: 2;'
       )
       )
-      template(v-if='showToc')
+      template(v-if='pageStore.showToc')
         //- TOC
         //- TOC
         .q-pa-md.flex.items-center
         .q-pa-md.flex.items-center
           q-icon.q-mr-sm(name='las la-stream', color='grey')
           q-icon.q-mr-sm(name='las la-stream', color='grey')
           .text-caption.text-grey-7 Contents
           .text-caption.text-grey-7 Contents
         .q-px-md.q-pb-sm
         .q-px-md.q-pb-sm
           q-tree(
           q-tree(
-            :nodes='toc'
+            :nodes='state.toc'
             node-key='key'
             node-key='key'
-            v-model:expanded='tocExpanded'
-            v-model:selected='tocSelected'
+            v-model:expanded='state.tocExpanded'
+            v-model:selected='state.tocSelected'
           )
           )
       //- Tags
       //- Tags
-      template(v-if='showTags')
-        q-separator(v-if='showToc')
+      template(v-if='pageStore.showTags')
+        q-separator(v-if='pageStore.showToc')
         .q-pa-md(
         .q-pa-md(
-          @mouseover='showTagsEditBtn = true'
-          @mouseleave='showTagsEditBtn = false'
+          @mouseover='state.showTagsEditBtn = true'
+          @mouseleave='state.showTagsEditBtn = false'
           )
           )
           .flex.items-center
           .flex.items-center
             q-icon.q-mr-sm(name='las la-tags', color='grey')
             q-icon.q-mr-sm(name='las la-tags', color='grey')
@@ -229,7 +160,7 @@ q-page.column
             q-space
             q-space
             transition(name='fade')
             transition(name='fade')
               q-btn(
               q-btn(
-                v-show='showTagsEditBtn'
+                v-show='state.showTagsEditBtn'
                 size='sm'
                 size='sm'
                 padding='none xs'
                 padding='none xs'
                 icon='las la-pen'
                 icon='las la-pen'
@@ -237,24 +168,24 @@ q-page.column
                 flat
                 flat
                 label='Edit'
                 label='Edit'
                 no-caps
                 no-caps
-                @click='tagEditMode = !tagEditMode'
+                @click='state.tagEditMode = !state.tagEditMode'
               )
               )
-          page-tags.q-mt-sm(:edit='tagEditMode')
-      template(v-if='allowRatings && ratingsMode !== `off`')
-        q-separator(v-if='showToc || showTags')
+          page-tags.q-mt-sm(:edit='state.tagEditMode')
+      template(v-if='pageStore.allowRatings && pageStore.ratingsMode !== `off`')
+        q-separator(v-if='pageStore.showToc || pageStore.showTags')
         //- Rating
         //- Rating
         .q-pa-md.flex.items-center
         .q-pa-md.flex.items-center
           q-icon.q-mr-sm(name='las la-star-half-alt', color='grey')
           q-icon.q-mr-sm(name='las la-star-half-alt', color='grey')
           .text-caption.text-grey-7 Rate this page
           .text-caption.text-grey-7 Rate this page
         .q-px-md
         .q-px-md
           q-rating(
           q-rating(
-            v-if='ratingsMode === `stars`'
-            v-model='currentRating'
+            v-if='pageStore.ratingsMode === `stars`'
+            v-model='state.currentRating'
             icon='las la-star'
             icon='las la-star'
             color='secondary'
             color='secondary'
             size='sm'
             size='sm'
           )
           )
-          .flex.items-center(v-else-if='ratingsMode === `thumbs`')
+          .flex.items-center(v-else-if='pageStore.ratingsMode === `thumbs`')
             q-btn.acrylic-btn(
             q-btn.acrylic-btn(
               flat
               flat
               icon='las la-thumbs-down'
               icon='las la-thumbs-down'
@@ -350,206 +281,226 @@ q-page.column
         q-tooltip(anchor='center left' self='center right') Delete Page
         q-tooltip(anchor='center left' self='center right') Delete Page
 
 
   q-dialog(
   q-dialog(
-    v-model='showSideDialog'
+    v-model='state.showSideDialog'
     position='right'
     position='right'
     full-height
     full-height
     transition-show='jump-left'
     transition-show='jump-left'
     transition-hide='jump-right'
     transition-hide='jump-right'
     class='floating-sidepanel'
     class='floating-sidepanel'
     )
     )
-    component(:is='sideDialogComponent')
+    component(:is='state.sideDialogComponent')
 
 
   q-dialog(
   q-dialog(
-    v-model='showGlobalDialog'
+    v-model='state.showGlobalDialog'
     transition-show='jump-up'
     transition-show='jump-up'
     transition-hide='jump-down'
     transition-hide='jump-down'
     )
     )
-    component(:is='globalDialogComponent')
+    component(:is='state.globalDialogComponent')
 </template>
 </template>
 
 
-<script>
-import { get, sync } from 'vuex-pathify'
-import IconPickerDialog from '../components/IconPickerDialog.vue'
+<script setup>
+import { useMeta, useQuasar, setCssVar } from 'quasar'
+import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+
+import { usePageStore } from 'src/stores/page'
+import { useSiteStore } from '../stores/site'
+
+// COMPONENTS
+
 import SocialSharingMenu from '../components/SocialSharingMenu.vue'
 import SocialSharingMenu from '../components/SocialSharingMenu.vue'
 import PageDataDialog from '../components/PageDataDialog.vue'
 import PageDataDialog from '../components/PageDataDialog.vue'
 import PageTags from '../components/PageTags.vue'
 import PageTags from '../components/PageTags.vue'
 import PagePropertiesDialog from '../components/PagePropertiesDialog.vue'
 import PagePropertiesDialog from '../components/PagePropertiesDialog.vue'
 import PageSaveDialog from '../components/PageSaveDialog.vue'
 import PageSaveDialog from '../components/PageSaveDialog.vue'
-import EditorWysiwyg from '../components/EditorWysiwyg.vue'
-
-export default {
-  name: 'PageIndex',
-  components: {
-    EditorWysiwyg,
-    IconPickerDialog,
-    PageDataDialog,
-    PagePropertiesDialog,
-    PageSaveDialog,
-    PageTags,
-    SocialSharingMenu
-  },
-  data () {
-    return {
-      showSideDialog: false,
-      sideDialogComponent: null,
-      showGlobalDialog: false,
-      globalDialogComponent: null,
-      showTagsEditBtn: false,
-      tagEditMode: false,
-      toc: [
-        {
-          key: 'h1-0',
-          label: 'Introduction'
-        },
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const pageStore = usePageStore()
+const siteStore = useSiteStore()
+
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: pageStore.title
+})
+
+// DATA
+
+const state = reactive({
+  showSideDialog: false,
+  sideDialogComponent: null,
+  showGlobalDialog: false,
+  globalDialogComponent: null,
+  showTagsEditBtn: false,
+  tagEditMode: false,
+  toc: [
+    {
+      key: 'h1-0',
+      label: 'Introduction'
+    },
+    {
+      key: 'h1-1',
+      label: 'Planets',
+      children: [
         {
         {
-          key: 'h1-1',
-          label: 'Planets',
+          key: 'h2-0',
+          label: 'Earth',
           children: [
           children: [
             {
             {
-              key: 'h2-0',
-              label: 'Earth',
+              key: 'h3-0',
+              label: 'Countries',
               children: [
               children: [
                 {
                 {
-                  key: 'h3-0',
-                  label: 'Countries',
+                  key: 'h4-0',
+                  label: 'Cities',
                   children: [
                   children: [
                     {
                     {
-                      key: 'h4-0',
-                      label: 'Cities',
+                      key: 'h5-0',
+                      label: 'Montreal',
                       children: [
                       children: [
                         {
                         {
-                          key: 'h5-0',
-                          label: 'Montreal',
-                          children: [
-                            {
-                              key: 'h6-0',
-                              label: 'Districts'
-                            }
-                          ]
+                          key: 'h6-0',
+                          label: 'Districts'
                         }
                         }
                       ]
                       ]
                     }
                     }
                   ]
                   ]
                 }
                 }
               ]
               ]
-            },
-            {
-              key: 'h2-1',
-              label: 'Mars'
-            },
-            {
-              key: 'h2-2',
-              label: 'Jupiter'
             }
             }
           ]
           ]
+        },
+        {
+          key: 'h2-1',
+          label: 'Mars'
+        },
+        {
+          key: 'h2-2',
+          label: 'Jupiter'
         }
         }
-      ],
-      tocExpanded: ['h1-0', 'h1-1'],
-      tocSelected: [],
-      currentRating: 3,
-      thumbStyle: {
-        right: '2px',
-        borderRadius: '5px',
-        backgroundColor: '#000',
-        width: '5px',
-        opacity: 0.15
-      },
-      barStyle: {
-        backgroundColor: '#FAFAFA',
-        width: '9px',
-        opacity: 1
-      }
-    }
-  },
-  computed: {
-    mode: sync('page/mode', false),
-    editorMode: get('page/editorMode', false),
-    breadcrumbs: get('page/breadcrumbs', false),
-    title: sync('page/title', false),
-    description: sync('page/description', false),
-    relations: get('page/relations', false),
-    tags: sync('page/tags', false),
-    ratingsMode: get('site/ratingsMode', false),
-    allowComments: get('page/allowComments', false),
-    allowContributions: get('page/allowContributions', false),
-    allowRatings: get('page/allowRatings', false),
-    showSidebar () {
-      return this.$store.get('page/showSidebar') && this.$store.get('site/showSidebar')
-    },
-    showTags: get('page/showTags', false),
-    showToc: get('page/showToc', false),
-    tocDepth: get('page/tocDepth', false),
-    isPublished: get('page/isPublished', false),
-    pageIcon: sync('page/icon', false),
-    render: get('page/render', false),
-    editorComponent () {
-      return this.$store.get('page/editor') ? `editor-${this.$store.get('page/editor')}` : null
-    },
-    relationsLeft () {
-      return this.relations ? this.relations.filter(r => r.position === 'left') : []
-    },
-    relationsCenter () {
-      return this.relations ? this.relations.filter(r => r.position === 'center') : []
-    },
-    relationsRight () {
-      return this.relations ? this.relations.filter(r => r.position === 'right') : []
-    },
-    editMode () {
-      return this.mode === 'edit'
-    },
-    editCreateMode () {
-      return this.mode === 'edit' && this.editorMode === 'create'
+      ]
     }
     }
-  },
-  watch: {
-    toc () {
-      this.refreshTocExpanded()
-    },
-    tocDepth () {
-      this.refreshTocExpanded()
-    }
-  },
-  mounted () {
-    this.refreshTocExpanded()
-  },
-  methods: {
-    togglePageProperties () {
-      this.sideDialogComponent = 'PagePropertiesDialog'
-      this.showSideDialog = true
-    },
-    togglePageData () {
-      this.sideDialogComponent = 'PageDataDialog'
-      this.showSideDialog = true
-    },
-    savePage () {
-      this.globalDialogComponent = 'PageSaveDialog'
-      this.showGlobalDialog = true
-    },
-    refreshTocExpanded (baseToc) {
-      const toExpand = []
-      let isRootNode = false
-      if (!baseToc) {
-        baseToc = this.toc
-        isRootNode = true
-      }
-      if (baseToc.length > 0) {
-        for (const node of baseToc) {
-          if (node.key >= `h${this.tocDepth.min}` && node.key <= `h${this.tocDepth.max}`) {
-            toExpand.push(node.key)
-          }
-          if (node.children?.length && node.key < `h${this.tocDepth.max}`) {
-            toExpand.push(...this.refreshTocExpanded(node.children))
-          }
-        }
+  ],
+  tocExpanded: ['h1-0', 'h1-1'],
+  tocSelected: [],
+  currentRating: 3
+})
+const thumbStyle = {
+  right: '2px',
+  borderRadius: '5px',
+  backgroundColor: '#000',
+  width: '5px',
+  opacity: 0.15
+}
+const barStyle = {
+  backgroundColor: '#FAFAFA',
+  width: '9px',
+  opacity: 1
+}
+
+// COMPUTED
+
+const showSidebar = computed(() => {
+  return pageStore.showSidebar && siteStore.showSidebar
+})
+const editorComponent = computed(() => {
+  return pageStore.editor ? `editor-${pageStore.editor}` : null
+})
+const relationsLeft = computed(() => {
+  return pageStore.relations ? pageStore.relations.filter(r => r.position === 'left') : []
+})
+const relationsCenter = computed(() => {
+  return pageStore.relations ? pageStore.relations.filter(r => r.position === 'center') : []
+})
+const relationsRight = computed(() => {
+  return pageStore.relations ? pageStore.relations.filter(r => r.position === 'right') : []
+})
+const editMode = computed(() => {
+  return pageStore.mode === 'edit'
+})
+const editCreateMode = computed(() => {
+  return pageStore.mode === 'edit' && pageStore.mode === 'create'
+})
+const editUrl = computed(() => {
+  let pagePath = siteStore.useLocales ? `${pageStore.locale}/` : ''
+  pagePath += !pageStore.path ? 'home' : pageStore.path
+  return `/_edit/${pagePath}`
+})
+
+// WATCHERS
+
+watch(() => state.toc, refreshTocExpanded)
+watch(() => pageStore.tocDepth, refreshTocExpanded)
+
+// METHODS
+
+function getFullPath ({ locale, path }) {
+  if (siteStore.useLocales) {
+    return `/${locale}/${path}`
+  } else {
+    return `/${path}`
+  }
+}
+
+function togglePageProperties () {
+  state.sideDialogComponent = 'PagePropertiesDialog'
+  state.showSideDialog = true
+}
+
+function togglePageData () {
+  state.sideDialogComponent = 'PageDataDialog'
+  state.showSideDialog = true
+}
+
+function savePage () {
+  state.globalDialogComponent = 'PageSaveDialog'
+  state.showGlobalDialog = true
+}
+
+function refreshTocExpanded (baseToc) {
+  const toExpand = []
+  let isRootNode = false
+  if (!baseToc) {
+    baseToc = state.toc
+    isRootNode = true
+  }
+  if (baseToc.length > 0) {
+    for (const node of baseToc) {
+      if (node.key >= `h${pageStore.tocDepth.min}` && node.key <= `h${pageStore.tocDepth.max}`) {
+        toExpand.push(node.key)
       }
       }
-      if (isRootNode) {
-        this.tocExpanded = toExpand
-      } else {
-        return toExpand
+      if (node.children?.length && node.key < `h${pageStore.tocDepth.max}`) {
+        toExpand.push(...refreshTocExpanded(node.children))
       }
       }
     }
     }
   }
   }
+  if (isRootNode) {
+    state.tocExpanded = toExpand
+  } else {
+    return toExpand
+  }
 }
 }
+
+// MOUNTED
+
+onMounted(() => {
+  refreshTocExpanded()
+})
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">

+ 13 - 10
ux/src/router/routes.js

@@ -1,13 +1,13 @@
 
 
 const routes = [
 const routes = [
-  // {
-  //   path: '/',
-  //   component: () => import('../layouts/MainLayout.vue'),
-  //   children: [
-  //     { path: '', component: () => import('../pages/Index.vue') },
-  //     { path: 'n/:editor?', component: () => import('../pages/Index.vue') }
-  //   ]
-  // },
+  {
+    path: '/',
+    component: () => import('../layouts/MainLayout.vue'),
+    children: [
+      { path: '', component: () => import('../pages/Index.vue') }
+      // { path: 'n/:editor?', component: () => import('../pages/Index.vue') }
+    ]
+  },
   {
   {
     path: '/login',
     path: '/login',
     component: () => import('layouts/AuthLayout.vue'),
     component: () => import('layouts/AuthLayout.vue'),
@@ -36,7 +36,6 @@ const routes = [
       { path: ':siteid/locale', component: () => import('pages/AdminLocale.vue') },
       { path: ':siteid/locale', component: () => import('pages/AdminLocale.vue') },
       { path: ':siteid/login', component: () => import('pages/AdminLogin.vue') },
       { path: ':siteid/login', component: () => import('pages/AdminLogin.vue') },
       { path: ':siteid/navigation', component: () => import('pages/AdminNavigation.vue') },
       { path: ':siteid/navigation', component: () => import('pages/AdminNavigation.vue') },
-      // { path: ':siteid/rendering', component: () => import('pages/AdminRendering.vue') },
       { path: ':siteid/storage/:id?', component: () => import('pages/AdminStorage.vue') },
       { path: ':siteid/storage/:id?', component: () => import('pages/AdminStorage.vue') },
       { path: ':siteid/theme', component: () => import('pages/AdminTheme.vue') },
       { path: ':siteid/theme', component: () => import('pages/AdminTheme.vue') },
       // -> Users
       // -> Users
@@ -48,6 +47,7 @@ const routes = [
       { path: 'extensions', component: () => import('pages/AdminExtensions.vue') },
       { path: 'extensions', component: () => import('pages/AdminExtensions.vue') },
       { path: 'instances', component: () => import('pages/AdminInstances.vue') },
       { path: 'instances', component: () => import('pages/AdminInstances.vue') },
       { path: 'mail', component: () => import('pages/AdminMail.vue') },
       { path: 'mail', component: () => import('pages/AdminMail.vue') },
+      // { path: 'rendering', component: () => import('pages/AdminRendering.vue') },
       { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
       { path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
       { path: 'security', component: () => import('pages/AdminSecurity.vue') },
       { path: 'security', component: () => import('pages/AdminSecurity.vue') },
       { path: 'system', component: () => import('pages/AdminSystem.vue') },
       { path: 'system', component: () => import('pages/AdminSystem.vue') },
@@ -74,7 +74,10 @@ const routes = [
   // but you can also remove it
   // but you can also remove it
   {
   {
     path: '/:catchAll(.*)*',
     path: '/:catchAll(.*)*',
-    component: () => import('pages/ErrorNotFound.vue')
+    component: () => import('../layouts/MainLayout.vue'),
+    children: [
+      { path: '', component: () => import('../pages/Index.vue') }
+    ]
   }
   }
 ]
 ]