Ver Fonte

feat: File Manager improvements + system flags

Nicolas Giard há 2 anos atrás
pai
commit
b4769b9ac6

+ 3 - 2
server/app/data.yml

@@ -71,8 +71,9 @@ defaults:
       authJwtRenewablePeriod: '14d'
       authJwtRenewablePeriod: '14d'
       enforceSameOriginReferrerPolicy: true
       enforceSameOriginReferrerPolicy: true
     flags:
     flags:
-      ldapdebug: false
-      sqllog: false
+      experimental: false
+      authDebug: false
+      sqlLog: false
     # System defaults
     # System defaults
     channel: NEXT
     channel: NEXT
     cors:
     cors:

+ 1 - 1
server/core/config.js

@@ -133,7 +133,7 @@ module.exports = {
    * Apply Dev Flags
    * Apply Dev Flags
    */
    */
   async applyFlags() {
   async applyFlags() {
-    WIKI.db.knex.client.config.debug = WIKI.config.flags.sqllog
+    WIKI.db.knex.client.config.debug = WIKI.config.flags.sqlLog
   },
   },
 
 
   /**
   /**

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

@@ -285,7 +285,7 @@ exports.up = async knex => {
       table.specificType('folderPath', 'ltree').index().index('tree_folderpath_gist_index', { indexType: 'GIST' })
       table.specificType('folderPath', 'ltree').index().index('tree_folderpath_gist_index', { indexType: 'GIST' })
       table.string('fileName').notNullable().index()
       table.string('fileName').notNullable().index()
       table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
       table.enu('type', ['folder', 'page', 'asset']).notNullable().index()
-      table.uuid('targetId').index()
+      table.string('localeCode', 5).notNullable().defaultTo('en').index()
       table.string('title').notNullable()
       table.string('title').notNullable()
       table.jsonb('meta').notNullable().defaultTo('{}')
       table.jsonb('meta').notNullable().defaultTo('{}')
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
@@ -372,6 +372,7 @@ exports.up = async knex => {
       table.string('localeCode', 5).references('code').inTable('locales').index()
       table.string('localeCode', 5).references('code').inTable('locales').index()
       table.uuid('authorId').notNullable().references('id').inTable('users').index()
       table.uuid('authorId').notNullable().references('id').inTable('users').index()
       table.uuid('creatorId').notNullable().references('id').inTable('users').index()
       table.uuid('creatorId').notNullable().references('id').inTable('users').index()
+      table.uuid('ownerId').notNullable().references('id').inTable('users').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
       table.uuid('siteId').notNullable().references('id').inTable('sites').index()
     })
     })
     .table('storage', table => {
     .table('storage', table => {
@@ -439,6 +440,14 @@ exports.up = async knex => {
         guestUserId: userGuestId
         guestUserId: userGuestId
       }
       }
     },
     },
+    {
+      key: 'flags',
+      value: {
+        experimental: false,
+        authDebug: false,
+        sqlLog: false
+      }
+    },
     {
     {
       key: 'icons',
       key: 'icons',
       value: {
       value: {

+ 6 - 7
server/graph/resolvers/system.js

@@ -11,9 +11,7 @@ const graphHelper = require('../../helpers/graph')
 module.exports = {
 module.exports = {
   Query: {
   Query: {
     systemFlags () {
     systemFlags () {
-      return _.transform(WIKI.config.flags, (result, value, key) => {
-        result.push({ key, value })
-      }, [])
+      return WIKI.config.flags
     },
     },
     async systemInfo () { return {} },
     async systemInfo () { return {} },
     async systemExtensions () {
     async systemExtensions () {
@@ -150,9 +148,10 @@ module.exports = {
       }
       }
     },
     },
     async updateSystemFlags (obj, args, context) {
     async updateSystemFlags (obj, args, context) {
-      WIKI.config.flags = _.transform(args.flags, (result, row) => {
-        _.set(result, row.key, row.value)
-      }, {})
+      WIKI.config.flags = {
+        ...WIKI.config.flags,
+        ...args.flags
+      }
       await WIKI.configSvc.applyFlags()
       await WIKI.configSvc.applyFlags()
       await WIKI.configSvc.saveToDb(['flags'])
       await WIKI.configSvc.saveToDb(['flags'])
       return {
       return {
@@ -164,7 +163,7 @@ module.exports = {
       // TODO: broadcast config update
       // TODO: broadcast config update
       await WIKI.configSvc.saveToDb(['security'])
       await WIKI.configSvc.saveToDb(['security'])
       return {
       return {
-        status: graphHelper.generateSuccess('System Security configuration applied successfully')
+        operation: graphHelper.generateSuccess('System Security configuration applied successfully')
       }
       }
     }
     }
   },
   },

+ 6 - 6
server/graph/resolvers/tree.js

@@ -7,7 +7,7 @@ const typeResolvers = {
   asset: 'TreeItemAsset'
   asset: 'TreeItemAsset'
 }
 }
 
 
-const rePathName = /^[a-z0-9_]+$/
+const rePathName = /^[a-z0-9-]+$/
 const reTitle = /^[^<>"]+$/
 const reTitle = /^[^<>"]+$/
 
 
 module.exports = {
 module.exports = {
@@ -41,7 +41,7 @@ module.exports = {
       if (args.parentId) {
       if (args.parentId) {
         const parent = await WIKI.db.knex('tree').where('id', args.parentId).first()
         const parent = await WIKI.db.knex('tree').where('id', args.parentId).first()
         if (parent) {
         if (parent) {
-          parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
+          parentPath = (parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName).replaceAll('-', '_')
         }
         }
       } else if (args.parentPath) {
       } else if (args.parentPath) {
         parentPath = args.parentPath.replaceAll('/', '.').replaceAll('-', '_').toLowerCase()
         parentPath = args.parentPath.replaceAll('/', '.').replaceAll('-', '_').toLowerCase()
@@ -101,11 +101,11 @@ module.exports = {
           if (parent) {
           if (parent) {
             parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
             parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
           }
           }
+          parentPath = parentPath.replaceAll('-', '_')
         }
         }
 
 
         // Validate path name
         // Validate path name
-        const pathName = args.pathName.replaceAll('-', '_')
-        if (!rePathName.test(pathName)) {
+        if (!rePathName.test(args.pathName)) {
           throw new Error('ERR_INVALID_PATH_NAME')
           throw new Error('ERR_INVALID_PATH_NAME')
         }
         }
 
 
@@ -118,7 +118,7 @@ module.exports = {
         const existingFolder = await WIKI.db.knex('tree').where({
         const existingFolder = await WIKI.db.knex('tree').where({
           siteId: args.siteId,
           siteId: args.siteId,
           folderPath: parentPath,
           folderPath: parentPath,
-          fileName: pathName
+          fileName: args.pathName
         }).first()
         }).first()
         if (existingFolder) {
         if (existingFolder) {
           throw new Error('ERR_FOLDER_ALREADY_EXISTS')
           throw new Error('ERR_FOLDER_ALREADY_EXISTS')
@@ -127,7 +127,7 @@ module.exports = {
         // Create folder
         // Create folder
         await WIKI.db.knex('tree').insert({
         await WIKI.db.knex('tree').insert({
           folderPath: parentPath,
           folderPath: parentPath,
-          fileName: pathName,
+          fileName: args.pathName,
           type: 'folder',
           type: 'folder',
           title: args.title,
           title: args.title,
           siteId: args.siteId
           siteId: args.siteId

+ 2 - 12
server/graph/schemas/system.graphql

@@ -4,7 +4,7 @@
 
 
 extend type Query {
 extend type Query {
   systemExtensions: [SystemExtension]
   systemExtensions: [SystemExtension]
-  systemFlags: [SystemFlag]
+  systemFlags: JSON
   systemInfo: SystemInfo
   systemInfo: SystemInfo
   systemInstances: [SystemInstance]
   systemInstances: [SystemInstance]
   systemSecurity: SystemSecurity
   systemSecurity: SystemSecurity
@@ -31,7 +31,7 @@ extend type Mutation {
   ): DefaultResponse
   ): DefaultResponse
 
 
   updateSystemFlags(
   updateSystemFlags(
-    flags: [SystemFlagInput]!
+    flags: JSON!
   ): DefaultResponse
   ): DefaultResponse
 
 
   updateSystemSecurity(
   updateSystemSecurity(
@@ -60,16 +60,6 @@ extend type Mutation {
 # TYPES
 # TYPES
 # -----------------------------------------------
 # -----------------------------------------------
 
 
-type SystemFlag {
-  key: String
-  value: Boolean
-}
-
-input SystemFlagInput {
-  key: String!
-  value: Boolean!
-}
-
 type SystemInfo {
 type SystemInfo {
   configFile: String
   configFile: String
   cpuCores: Int
   cpuCores: Int

+ 42 - 4
server/models/pages.js

@@ -310,12 +310,12 @@ module.exports = class Page extends Model {
       },
       },
       contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
       contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
       description: opts.description,
       description: opts.description,
-      // dotPath: dotPath,
       editor: opts.editor,
       editor: opts.editor,
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
       hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
       icon: opts.icon,
       icon: opts.icon,
       isBrowsable: opts.isBrowsable ?? true,
       isBrowsable: opts.isBrowsable ?? true,
       localeCode: opts.locale,
       localeCode: opts.locale,
+      ownerId: opts.user.id,
       path: opts.path,
       path: opts.path,
       publishState: opts.publishState,
       publishState: opts.publishState,
       publishEndDate: opts.publishEndDate?.toISO(),
       publishEndDate: opts.publishEndDate?.toISO(),
@@ -339,6 +339,29 @@ 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)
 
 
+    // -> Add to tree
+    const pathParts = page.path.split('/')
+    await WIKI.db.knex('tree').insert({
+      id: page.id,
+      folderPath: _.initial(pathParts).join('/'),
+      fileName: _.last(pathParts),
+      type: 'page',
+      localeCode: page.localeCode,
+      title: page.title,
+      meta: {
+        authorId: page.authorId,
+        contentType: page.contentType,
+        creatorId: page.creatorId,
+        description: page.description,
+        isBrowsable: page.isBrowsable,
+        ownerId: page.ownerId,
+        publishState: page.publishState,
+        publishEndDate: page.publishEndDate,
+        publishStartDate: page.publishStartDate
+      },
+      siteId: page.siteId
+    })
+
     return page
     return page
     // TODO: Handle remaining flow
     // TODO: Handle remaining flow
 
 
@@ -590,6 +613,23 @@ module.exports = class Page extends Model {
     }
     }
     WIKI.events.outbound.emit('deletePageFromCache', page.hash)
     WIKI.events.outbound.emit('deletePageFromCache', page.hash)
 
 
+    // -> Update tree
+    await WIKI.db.knex('tree').where('id', page.id).update({
+      title: page.title,
+      meta: {
+        authorId: page.authorId,
+        contentType: page.contentType,
+        creatorId: page.creatorId,
+        description: page.description,
+        isBrowsable: page.isBrowsable,
+        ownerId: page.ownerId,
+        publishState: page.publishState,
+        publishEndDate: page.publishEndDate,
+        publishStartDate: page.publishStartDate
+      },
+      updatedAt: page.updatedAt
+    })
+
     // // -> Update Search Index
     // // -> Update Search Index
     // const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
     // const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
     // page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
     // page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
@@ -948,12 +988,10 @@ module.exports = class Page extends Model {
 
 
     // -> Delete page
     // -> Delete page
     await WIKI.db.pages.query().delete().where('id', page.id)
     await WIKI.db.pages.query().delete().where('id', page.id)
+    await WIKI.db.knex('tree').where('id', page.id).del()
     await WIKI.db.pages.deletePageFromCache(page.hash)
     await WIKI.db.pages.deletePageFromCache(page.hash)
     WIKI.events.outbound.emit('deletePageFromCache', page.hash)
     WIKI.events.outbound.emit('deletePageFromCache', page.hash)
 
 
-    // -> Rebuild page tree
-    await WIKI.db.pages.rebuildTree()
-
     // -> Delete from Search Index
     // -> Delete from Search Index
     await WIKI.data.searchEngine.deleted(page)
     await WIKI.data.searchEngine.deleted(page)
 
 

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
ux/public/_assets/icons/ultraviolet-administrative-tools.svg


+ 8 - 0
ux/public/_assets/icons/ultraviolet-asciidoc.svg

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 80 80" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <path d="M13.687,7.865L34.903,44.827L71.741,44.827C71.99,43.253 72.114,41.595 72.114,39.938C72.114,22.244 57.735,7.865 40,7.865L13.687,7.865Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
+    <path d="M7.886,19.716L22.306,44.869L7.886,44.869L7.886,19.716Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
+    <path d="M7.886,55.808L7.886,65.878C7.886,69.317 10.662,72.135 14.143,72.135L37.97,72.135L28.605,55.808C28.605,55.85 7.886,55.85 7.886,55.808Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
+    <path d="M49.696,70.602C57.487,68.157 63.992,62.77 67.97,55.767L41.16,55.767L49.696,70.602Z" style="fill:rgb(152,204,253);fill-rule:nonzero;stroke:rgb(71,136,199);stroke-width:2px;"/>
+</svg>

+ 6 - 0
ux/src/App.vue

@@ -5,6 +5,7 @@ router-view
 <script setup>
 <script setup>
 import { nextTick, onMounted, reactive, watch } from 'vue'
 import { nextTick, onMounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useRouter, useRoute } from 'vue-router'
+import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
 import { useUserStore } from 'src/stores/user'
 import { setCssVar, useQuasar } from 'quasar'
 import { setCssVar, useQuasar } from 'quasar'
@@ -17,6 +18,7 @@ const $q = useQuasar()
 
 
 // STORES
 // STORES
 
 
+const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
 const userStore = useUserStore()
 
 
@@ -67,6 +69,10 @@ if (typeof siteConfig !== 'undefined') {
 
 
 router.beforeEach(async (to, from) => {
 router.beforeEach(async (to, from) => {
   siteStore.routerLoading = true
   siteStore.routerLoading = true
+  // System Flags
+  if (!flagsStore.loaded) {
+    flagsStore.load()
+  }
   // Site Info
   // Site Info
   if (!siteStore.id) {
   if (!siteStore.id) {
     console.info('No pre-cached site config. Loading site info...')
     console.info('No pre-cached site config. Loading site info...')

+ 145 - 47
ux/src/components/FileManager.vue

@@ -38,6 +38,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
   q-drawer.fileman-left(:model-value='true', :width='350')
   q-drawer.fileman-left(:model-value='true', :width='350')
     .q-px-md.q-pb-sm
     .q-px-md.q-pb-sm
       tree(
       tree(
+        ref='treeComp'
         :nodes='state.treeNodes'
         :nodes='state.treeNodes'
         :roots='state.treeRoots'
         :roots='state.treeRoots'
         v-model:selected='state.currentFolderId'
         v-model:selected='state.currentFolderId'
@@ -46,7 +47,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
         @context-action='treeContextAction'
         @context-action='treeContextAction'
         :display-mode='state.displayMode'
         :display-mode='state.displayMode'
       )
       )
-  q-drawer.fileman-right(:model-value='true', :width='350', side='right')
+  q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right')
     .q-pa-md
     .q-pa-md
       template(v-if='currentFileDetails')
       template(v-if='currentFileDetails')
         q-img.rounded-borders.q-mb-md(
         q-img.rounded-borders.q-mb-md(
@@ -143,7 +144,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
             color='grey'
             color='grey'
             :aria-label='t(`common.actions.refresh`)'
             :aria-label='t(`common.actions.refresh`)'
             icon='las la-redo-alt'
             icon='las la-redo-alt'
-            @click=''
+            @click='reloadFolder(state.currentFolderId)'
             )
             )
             q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
             q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.refresh`)}}
           q-separator.q-mr-sm(inset, vertical)
           q-separator.q-mr-sm(inset, vertical)
@@ -172,14 +173,21 @@ q-layout.fileman(view='hHh lpR lFr', container)
             icon='las la-cloud-upload-alt'
             icon='las la-cloud-upload-alt'
             @click='uploadFile'
             @click='uploadFile'
             )
             )
-      q-list.fileman-filelist
+      .fileman-emptylist(v-if='files.length < 1')
+        template(v-if='state.fileListLoading')
+          q-spinner.q-mr-sm(color='primary', size='xs', :thickness='3')
+          span.text-primary Loading...
+        template(v-else)
+          q-icon.q-mr-sm(name='las la-exclamation-triangle', size='sm')
+          span This folder is empty.
+      q-list.fileman-filelist(v-else)
         q-item(
         q-item(
           v-for='item of files'
           v-for='item of files'
           :key='item.id'
           :key='item.id'
           clickable
           clickable
           active-class='active'
           active-class='active'
           :active='item.id === state.currentFileId'
           :active='item.id === state.currentFileId'
-          @click.native='state.currentFileId = item.id'
+          @click.native='selectItem(item)'
           @dblclick.native='openItem(item)'
           @dblclick.native='openItem(item)'
           )
           )
           q-item-section.fileman-filelist-icon(avatar)
           q-item-section.fileman-filelist-icon(avatar)
@@ -229,7 +237,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
                   q-item-section.text-negative Delete
                   q-item-section.text-negative Delete
   q-footer
   q-footer
     q-bar.fileman-path
     q-bar.fileman-path
-      small.text-caption.text-grey-7 / foo / bar
+      small.text-caption.text-grey-7 {{folderPath}}
 
 
   input(
   input(
     type='file'
     type='file'
@@ -258,6 +266,7 @@ import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
 
 
 import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
 import FolderCreateDialog from 'src/components/FolderCreateDialog.vue'
+import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
 
 
 // QUASAR
 // QUASAR
 
 
@@ -277,50 +286,34 @@ const { t } = useI18n()
 const state = reactive({
 const state = reactive({
   loading: 0,
   loading: 0,
   search: '',
   search: '',
-  currentFolderId: '',
-  currentFileId: '',
+  currentFolderId: null,
+  currentFileId: null,
   treeNodes: {},
   treeNodes: {},
   treeRoots: [],
   treeRoots: [],
   displayMode: 'title',
   displayMode: 'title',
   isUploading: false,
   isUploading: false,
   shouldCancelUpload: false,
   shouldCancelUpload: false,
   uploadPercentage: 0,
   uploadPercentage: 0,
-  fileList: [
-    {
-      id: '1',
-      type: 'folder',
-      title: 'Beep Boop',
-      children: 19
-    },
-    {
-      id: '2',
-      type: 'folder',
-      title: 'Second Folder',
-      children: 0
-    },
-    {
-      id: '3',
-      type: 'page',
-      title: 'Some Page',
-      pageType: 'markdown',
-      updatedAt: '2022-11-24T18:27:00Z'
-    },
-    {
-      id: '4',
-      type: 'file',
-      title: 'Important Document',
-      fileType: 'pdf',
-      fileSize: 19000
-    }
-  ]
+  fileList: [],
+  fileListLoading: false
 })
 })
 
 
 // REFS
 // REFS
 
 
 const fileIpt = ref(null)
 const fileIpt = ref(null)
+const treeComp = ref(null)
 
 
 // COMPUTED
 // COMPUTED
 
 
+const folderPath = computed(() => {
+  if (!state.currentFolderId) {
+    return '/'
+  } else {
+    const folderNode = state.treeNodes[state.currentFolderId] ?? {}
+    return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
+  }
+})
+
 const files = computed(() => {
 const files = computed(() => {
   return state.fileList.map(f => {
   return state.fileList.map(f => {
     switch (f.type) {
     switch (f.type) {
@@ -334,7 +327,7 @@ const files = computed(() => {
         f.caption = t(`fileman.${f.pageType}PageType`)
         f.caption = t(`fileman.${f.pageType}PageType`)
         break
         break
       }
       }
-      case 'file': {
+      case 'asset': {
         f.icon = fileTypes[f.fileType]?.icon ?? ''
         f.icon = fileTypes[f.fileType]?.icon ?? ''
         f.side = filesize(f.fileSize)
         f.side = filesize(f.fileSize)
         if (fileTypes[f.fileType]) {
         if (fileTypes[f.fileType]) {
@@ -382,7 +375,7 @@ const currentFileDetails = computed(() => {
         })
         })
         break
         break
       }
       }
-      case 'file': {
+      case 'asset': {
         items.push({
         items.push({
           label: t('fileman.detailsAssetType'),
           label: t('fileman.detailsAssetType'),
           value: fileTypes[item.fileType] ? t(`fileman.${item.fileType}FileType`) : t('fileman.unknownFileType', { type: item.fileType.toUpperCase() })
           value: fileTypes[item.fileType] ? t(`fileman.${item.fileType}FileType`) : t('fileman.unknownFileType', { type: item.fileType.toUpperCase() })
@@ -405,8 +398,8 @@ const currentFileDetails = computed(() => {
 
 
 // WATCHERS
 // WATCHERS
 
 
-watch(() => state.currentFolderId, (newValue) => {
-  state.currentFileId = null
+watch(() => state.currentFolderId, async (newValue) => {
+  await loadTree(newValue)
 })
 })
 
 
 // METHODS
 // METHODS
@@ -420,7 +413,16 @@ async function treeLazyLoad (nodeId, { done, fail }) {
   done()
   done()
 }
 }
 
 
-async function loadTree (parentId, types) {
+async function loadTree (parentId, types, noCache = false) {
+  if (!parentId) {
+    parentId = null
+    state.treeRoots = []
+  }
+  if (parentId === state.currentFolderId) {
+    state.fileListLoading = true
+    state.currentFileId = null
+    state.fileList = []
+  }
   try {
   try {
     const resp = await APOLLO_CLIENT.query({
     const resp = await APOLLO_CLIENT.query({
       query: gql`
       query: gql`
@@ -476,15 +478,58 @@ async function loadTree (parentId, types) {
       for (const item of items) {
       for (const item of items) {
         switch (item.__typename) {
         switch (item.__typename) {
           case 'TreeItemFolder': {
           case 'TreeItemFolder': {
-            state.treeNodes[item.id] = {
-              text: item.title,
-              fileName: item.fileName,
-              children: []
+            // -> Tree Nodes
+            if (!state.treeNodes[item.id] || (parentId && !treeComp.value.isLoaded(item.id))) {
+              state.treeNodes[item.id] = {
+                folderPath: item.folderPath,
+                fileName: item.fileName,
+                title: item.title,
+                children: []
+              }
+              if (item.folderPath) {
+                if (!state.treeNodes[parentId].children.includes(item.id)) {
+                  state.treeNodes[parentId].children.push(item.id)
+                }
+              }
             }
             }
+
+            // -> Set Tree Roots
             if (!item.folderPath) {
             if (!item.folderPath) {
               newTreeRoots.push(item.id)
               newTreeRoots.push(item.id)
-            } else {
-              state.treeNodes[parentId].children.push(item.id)
+            }
+
+            // -> File List
+            if (parentId === state.currentFolderId) {
+              state.fileList.push({
+                id: item.id,
+                type: 'folder',
+                title: item.title,
+                children: 0
+              })
+            }
+            break
+          }
+          case 'TreeItemAsset': {
+            if (parentId === state.currentFolderId) {
+              state.fileList.push({
+                id: item.id,
+                type: 'asset',
+                title: item.title,
+                fileType: 'pdf',
+                fileSize: 19000
+              })
+            }
+            break
+          }
+          case 'TreeItemPage': {
+            if (parentId === state.currentFolderId) {
+              state.fileList.push({
+                id: item.id,
+                type: 'page',
+                title: item.title,
+                pageType: 'markdown',
+                updatedAt: '2022-11-24T18:27:00Z'
+              })
             }
             }
             break
             break
           }
           }
@@ -501,15 +546,23 @@ async function loadTree (parentId, types) {
       caption: err.message
       caption: err.message
     })
     })
   }
   }
+  if (parentId === state.currentFolderId) {
+    nextTick(() => {
+      state.fileListLoading = false
+    })
+  }
 }
 }
 
 
 function treeContextAction (nodeId, action) {
 function treeContextAction (nodeId, action) {
-  console.info(nodeId, action)
   switch (action) {
   switch (action) {
     case 'newFolder': {
     case 'newFolder': {
       newFolder(nodeId)
       newFolder(nodeId)
       break
       break
     }
     }
+    case 'del': {
+      delFolder(nodeId)
+      break
+    }
   }
   }
 }
 }
 
 
@@ -524,6 +577,28 @@ function newFolder (parentId) {
   })
   })
 }
 }
 
 
+function delFolder (folderId) {
+  $q.dialog({
+    component: FolderDeleteDialog,
+    componentProps: {
+      folderId,
+      folderName: state.treeNodes[folderId].title
+    }
+  }).onOk(() => {
+    for (const nodeId in state.treeNodes) {
+      if (state.treeNodes[nodeId].children.includes(folderId)) {
+        state.treeNodes[nodeId].children = state.treeNodes[nodeId].children.filter(c => c !== folderId)
+      }
+    }
+    delete state.treeNodes[folderId]
+  })
+}
+
+function reloadFolder (folderId) {
+  loadTree(folderId, null, true)
+  treeComp.value.resetLoaded()
+}
+
 // -> Upload Methods
 // -> Upload Methods
 
 
 function uploadFile () {
 function uploadFile () {
@@ -603,6 +678,15 @@ function uploadCancel () {
   state.uploadPercentage = 0
   state.uploadPercentage = 0
 }
 }
 
 
+function selectItem (item) {
+  if (item.type === 'folder') {
+    state.currentFolderId = item.id
+    treeComp.value.setOpened(item.id)
+  } else {
+    state.currentFileId = item.id
+  }
+}
+
 function openItem (item) {
 function openItem (item) {
   console.info(item.id)
   console.info(item.id)
 }
 }
@@ -662,6 +746,20 @@ onMounted(() => {
     }
     }
   }
   }
 
 
+  &-emptylist {
+    padding: 16px;
+    font-style: italic;
+    display: flex;
+    align-items: center;
+
+    @at-root .body--light & {
+      color: $grey-6;
+    }
+    @at-root .body--dark & {
+      color: $dark-4;
+    }
+  }
+
   &-filelist {
   &-filelist {
     padding: 8px 12px;
     padding: 8px 12px;
 
 

+ 109 - 0
ux/src/components/FolderDeleteDialog.vue

@@ -0,0 +1,109 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 550px; max-width: 850px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-delete-bin.svg', left, size='sm')
+      span {{t(`folderDeleteDialog.title`)}}
+    q-card-section
+      .text-body2
+        i18n-t(keypath='folderDeleteDialog.confirm')
+          template(v-slot:name)
+            strong {{folderName}}
+      .text-caption.text-grey.q-mt-sm {{t('folderDeleteDialog.folderId', { id: folderId })}}
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.delete`)'
+        color='negative'
+        padding='xs md'
+        @click='confirm'
+        :loading='state.isLoading'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive } from 'vue'
+
+// PROPS
+
+const props = defineProps({
+  folderId: {
+    type: String,
+    required: true
+  },
+  folderName: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  isLoading: false
+})
+
+// METHODS
+
+async function confirm () {
+  state.isLoading = true
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation deleteFolder ($id: UUID!) {
+          deleteFolder(folderId: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: props.folderId
+      }
+    })
+    if (resp?.data?.deleteFolder?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('folderDeleteDialog.deleteSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.deleteFolder?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+</script>

+ 15 - 9
ux/src/components/PageNewMenu.vue

@@ -11,15 +11,19 @@ q-menu.translucent-menu(
     q-item(clickable, @click='create(`markdown`)')
     q-item(clickable, @click='create(`markdown`)')
       blueprint-icon(icon='markdown')
       blueprint-icon(icon='markdown')
       q-item-section.q-pr-sm New Markdown Page
       q-item-section.q-pr-sm New Markdown Page
-    q-item(clickable, @click='create(`channel`)')
-      blueprint-icon(icon='chat')
-      q-item-section.q-pr-sm New Discussion Space
-    q-item(clickable, @click='create(`blog`)')
-      blueprint-icon(icon='typewriter-with-paper')
-      q-item-section.q-pr-sm New Blog Page
-    q-item(clickable, @click='create(`api`)')
-      blueprint-icon(icon='api')
-      q-item-section.q-pr-sm New API Documentation
+    q-item(clickable, @click='create(`asciidoc`)')
+      blueprint-icon(icon='asciidoc')
+      q-item-section.q-pr-sm New AsciiDoc Page
+    template(v-if='flagsStore.experimental')
+      q-item(clickable, @click='create(`channel`)')
+        blueprint-icon(icon='chat')
+        q-item-section.q-pr-sm New Discussion Space
+      q-item(clickable, @click='create(`blog`)')
+        blueprint-icon(icon='typewriter-with-paper')
+        q-item-section.q-pr-sm New Blog Page
+      q-item(clickable, @click='create(`api`)')
+        blueprint-icon(icon='api')
+        q-item-section.q-pr-sm New API Documentation
     q-item(clickable, @click='create(`redirect`)')
     q-item(clickable, @click='create(`redirect`)')
       blueprint-icon(icon='advance')
       blueprint-icon(icon='advance')
       q-item-section.q-pr-sm New Redirection
       q-item-section.q-pr-sm New Redirection
@@ -41,6 +45,7 @@ import { useQuasar } from 'quasar'
 
 
 import { usePageStore } from 'src/stores/page'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
+import { useFlagsStore } from 'src/stores/flags'
 
 
 // PROPS
 // PROPS
 
 
@@ -65,6 +70,7 @@ const $q = useQuasar()
 
 
 // STORES
 // STORES
 
 
+const flagsStore = useFlagsStore()
 const pageStore = usePageStore()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
 const siteStore = useSiteStore()
 
 

+ 67 - 15
ux/src/components/PageSaveDialog.vue

@@ -5,17 +5,23 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
       q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
       q-icon(name='img:/_assets/icons/fluent-save-as.svg', left, size='sm')
       span {{t('pageSaveDialog.title')}}
       span {{t('pageSaveDialog.title')}}
     .row.page-save-dialog-browser
     .row.page-save-dialog-browser
-      .col-4.q-px-sm
-        tree(
-          :nodes='state.treeNodes'
-          :roots='state.treeRoots'
-          v-model:selected='state.currentFolderId'
-          @lazy-load='treeLazyLoad'
-          :use-lazy-load='true'
-          @context-action='treeContextAction'
-          :context-action-list='[`newFolder`]'
-          :display-mode='state.displayMode'
-        )
+      .col-4
+        q-scroll-area(
+          :thumb-style='thumbStyle'
+          :bar-style='barStyle'
+          style='height: 300px'
+          )
+          .q-px-sm
+            tree(
+              :nodes='state.treeNodes'
+              :roots='state.treeRoots'
+              v-model:selected='state.currentFolderId'
+              @lazy-load='treeLazyLoad'
+              :use-lazy-load='true'
+              @context-action='treeContextAction'
+              :context-action-list='[`newFolder`]'
+              :display-mode='state.displayMode'
+            )
       .col-8
       .col-8
         q-list.page-save-dialog-filelist(dense)
         q-list.page-save-dialog-filelist(dense)
           q-item(
           q-item(
@@ -31,6 +37,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
               q-icon(:name='item.icon', size='sm')
               q-icon(:name='item.icon', size='sm')
             q-item-section
             q-item-section
               q-item-label {{item.title}}
               q-item-label {{item.title}}
+    .page-save-dialog-path.font-robotomono {{folderPath}}
     q-list.q-py-sm
     q-list.q-py-sm
       q-item
       q-item
         blueprint-icon(icon='new-document')
         blueprint-icon(icon='new-document')
@@ -197,8 +204,28 @@ const displayModes = [
   { value: 'path', label: t('pageSaveDialog.displayModePath') }
   { value: 'path', label: t('pageSaveDialog.displayModePath') }
 ]
 ]
 
 
+const thumbStyle = {
+  right: '1px',
+  borderRadius: '5px',
+  backgroundColor: '#666',
+  width: '5px',
+  opacity: 0.5
+}
+const barStyle = {
+  width: '7px'
+}
+
 // COMPUTED
 // COMPUTED
 
 
+const folderPath = computed(() => {
+  if (!state.currentFolderId) {
+    return '/'
+  } else {
+    const folderNode = state.treeNodes[state.currentFolderId] ?? {}
+    return folderNode.folderPath ? `/${folderNode.folderPath}/${folderNode.fileName}/` : `/${folderNode.fileName}/`
+  }
+})
+
 const files = computed(() => {
 const files = computed(() => {
   return state.fileList.map(f => {
   return state.fileList.map(f => {
     switch (f.type) {
     switch (f.type) {
@@ -274,8 +301,9 @@ async function loadTree (parentId, types) {
         switch (item.__typename) {
         switch (item.__typename) {
           case 'TreeItemFolder': {
           case 'TreeItemFolder': {
             state.treeNodes[item.id] = {
             state.treeNodes[item.id] = {
-              text: item.title,
+              folderPath: item.folderPath,
               fileName: item.fileName,
               fileName: item.fileName,
+              title: item.title,
               children: []
               children: []
             }
             }
             if (!item.folderPath) {
             if (!item.folderPath) {
@@ -336,16 +364,23 @@ onMounted(() => {
   &-browser {
   &-browser {
     height: 300px;
     height: 300px;
     max-height: 90vh;
     max-height: 90vh;
-    border-bottom: 1px solid $blue-grey-1;
+    border-bottom: 1px solid #FFF;
+
+    @at-root .body--light & {
+      border-bottom-color: $blue-grey-1;
+    }
+    @at-root .body--dark & {
+      border-bottom-color: $dark-3;
+    }
 
 
     > .col-4 {
     > .col-4 {
+      height: 300px;
+
       @at-root .body--light & {
       @at-root .body--light & {
         background-color: $blue-grey-1;
         background-color: $blue-grey-1;
-        border-bottom-color: $blue-grey-1;
       }
       }
       @at-root .body--dark & {
       @at-root .body--dark & {
         background-color: $dark-4;
         background-color: $dark-4;
-        border-bottom-color: $dark-4;
       }
       }
     }
     }
   }
   }
@@ -372,5 +407,22 @@ onMounted(() => {
     }
     }
   }
   }
 
 
+  &-path {
+    padding: 5px 16px;
+    font-size: 12px;
+    border-bottom: 1px solid #FFF;
+
+    @at-root .body--light & {
+      background-color: lighten($blue-grey-1, 4%);
+      border-bottom-color: $blue-grey-1;
+      color: $blue-grey-9;
+    }
+    @at-root .body--dark & {
+      background-color: darken($dark-4, 1%);
+      border-bottom-color: $dark-1;
+      color: $blue-grey-3;
+    }
+  }
+
 }
 }
 </style>
 </style>

+ 19 - 1
ux/src/components/TreeNav.vue

@@ -95,7 +95,7 @@ const state = reactive({
   opened: {}
   opened: {}
 })
 })
 
 
-// COMPOUTED
+// COMPUTED
 
 
 const selection = computed({
 const selection = computed({
   get () {
   get () {
@@ -120,6 +120,16 @@ function emitContextAction (nodeId, action) {
   emit('contextAction', nodeId, action)
   emit('contextAction', nodeId, action)
 }
 }
 
 
+function setOpened (nodeId) {
+  state.opened[nodeId] = true
+}
+function isLoaded (nodeId) {
+  return state.loaded[nodeId]
+}
+function resetLoaded (nodeId) {
+  state.loaded[nodeId] = false
+}
+
 // PROVIDE
 // PROVIDE
 
 
 provide('roots', toRef(props, 'roots'))
 provide('roots', toRef(props, 'roots'))
@@ -131,6 +141,14 @@ provide('selection', selection)
 provide('emitLazyLoad', emitLazyLoad)
 provide('emitLazyLoad', emitLazyLoad)
 provide('emitContextAction', emitContextAction)
 provide('emitContextAction', emitContextAction)
 
 
+// EXPOSE
+
+defineExpose({
+  setOpened,
+  isLoaded,
+  resetLoaded
+})
+
 // MOUNTED
 // MOUNTED
 
 
 onMounted(() => {
 onMounted(() => {

+ 1 - 1
ux/src/components/TreeNode.vue

@@ -7,7 +7,7 @@ li.treeview-node
       size='sm'
       size='sm'
       @click.stop='hasChildren ? toggleNode() : openNode()'
       @click.stop='hasChildren ? toggleNode() : openNode()'
       )
       )
-    .treeview-label-text {{displayMode === 'path' ? node.fileName : node.text}}
+    .treeview-label-text {{displayMode === 'path' ? node.fileName : node.title}}
     q-spinner.q-mr-xs(
     q-spinner.q-mr-xs(
       color='primary'
       color='primary'
       v-if='state.isLoading'
       v-if='state.isLoading'

+ 15 - 8
ux/src/i18n/locales/en.json

@@ -157,15 +157,13 @@
   "admin.extensions.requiresSharp": "Requires Sharp extension",
   "admin.extensions.requiresSharp": "Requires Sharp extension",
   "admin.extensions.subtitle": "Install extensions for extra functionality",
   "admin.extensions.subtitle": "Install extensions for extra functionality",
   "admin.extensions.title": "Extensions",
   "admin.extensions.title": "Extensions",
-  "admin.flags.hidedonatebtn.hint": "You have already donated to this project (thank you!) and want to hide the button from the administration area.",
-  "admin.flags.hidedonatebtn.label": "Hide Donate Button",
-  "admin.flags.ldapdebug.hint": "Log detailed debug info on LDAP/AD login attempts.",
-  "admin.flags.ldapdebug.label": "LDAP Debug",
-  "admin.flags.sqllog.hint": "Log all queries made to the database to console.",
-  "admin.flags.sqllog.label": "SQL Query Logging",
+  "admin.flags.authDebug.hint": "Log detailed debug info of all login / registration attempts.",
+  "admin.flags.authDebug.label": "Auth Debug",
+  "admin.flags.sqlLog.hint": "Log all queries made to the database to console.",
+  "admin.flags.sqlLog.label": "SQL Query Logging",
   "admin.flags.subtitle": "Low-level system flags for debugging or experimental purposes",
   "admin.flags.subtitle": "Low-level system flags for debugging or experimental purposes",
   "admin.flags.title": "Flags",
   "admin.flags.title": "Flags",
-  "admin.flags.warn.hint": "Doing so may result in data loss or broken installation!",
+  "admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
   "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
   "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
   "admin.general.allowComments": "Allow Comments",
   "admin.general.allowComments": "Allow Comments",
   "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
   "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.",
@@ -1603,5 +1601,14 @@
   "common.actions.duplicate": "Duplicate",
   "common.actions.duplicate": "Duplicate",
   "common.actions.moveTo": "Move To",
   "common.actions.moveTo": "Move To",
   "pageSaveDialog.displayModeTitle": "Title",
   "pageSaveDialog.displayModeTitle": "Title",
-  "pageSaveDialog.displayModePath": "Path"
+  "pageSaveDialog.displayModePath": "Path",
+  "folderDeleteDialog.title": "Confirm Delete Folder",
+  "folderDeleteDialog.confirm": "Are you sure you want to delete folder {name} and all its content?",
+  "folderDeleteDialog.folderId": "Folder ID {id}",
+  "folderDeleteDialog.deleteSuccess": "Folder has been deleted successfully.",
+  "admin.flags.experimental.label": "Experimental Features",
+  "admin.flags.experimental.hint": "Enable unstable / unfinished features. DO NOT enable in a production environment!",
+  "admin.flags.advanced.label": "Custom Configuration",
+  "admin.flags.advanced.hint": "Set custom configuration flags. Note that all values are public to all users! Do not insert senstive data.",
+  "admin.flags.saveSuccess": "Flags have been updated successfully."
 }
 }

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

@@ -29,7 +29,7 @@ q-layout.admin(view='hHh Lpr lff')
       :thumb-style='thumbStyle'
       :thumb-style='thumbStyle'
       :bar-style='barStyle'
       :bar-style='barStyle'
       )
       )
-      q-list.text-white(padding, dense)
+      q-list.text-white.q-pb-lg(padding, dense)
         q-item.q-mb-sm
         q-item.q-mb-sm
           q-item-section
           q-item-section
             q-btn.acrylic-btn(
             q-btn.acrylic-btn(

+ 15 - 13
ux/src/layouts/ProfileLayout.vue

@@ -5,19 +5,19 @@ q-layout(view='hHh Lpr lff')
     .layout-profile-card
     .layout-profile-card
       .layout-profile-sd
       .layout-profile-sd
         q-list
         q-list
-          q-item(
-            v-for='navItem of sidenav'
-            :key='navItem.key'
-            clickable
-            :to='`/_profile/` + navItem.key'
-            active-class='is-active'
-            :disabled='navItem.disabled'
-            v-ripple
-            )
-            q-item-section(side)
-              q-icon(:name='navItem.icon')
-            q-item-section
-              q-item-label {{navItem.label}}
+          template(v-for='navItem of sidenav' :key='navItem.key')
+            q-item(
+              v-if='!navItem.disabled || flagsStore.experimental'
+              clickable
+              :to='`/_profile/` + navItem.key'
+              active-class='is-active'
+              :disabled='navItem.disabled'
+              v-ripple
+              )
+              q-item-section(side)
+                q-icon(:name='navItem.icon')
+              q-item-section
+                q-item-label {{navItem.label}}
           q-separator.q-my-sm(inset)
           q-separator.q-my-sm(inset)
           q-item(
           q-item(
             clickable
             clickable
@@ -48,6 +48,7 @@ import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { useMeta, useQuasar } from 'quasar'
 import { onMounted, reactive, watch } from 'vue'
 import { onMounted, reactive, watch } from 'vue'
 
 
+import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
 import { useUserStore } from 'src/stores/user'
 
 
@@ -61,6 +62,7 @@ const $q = useQuasar()
 
 
 // STORES
 // STORES
 
 
+const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
 const userStore = useUserStore()
 
 

+ 110 - 76
ux/src/pages/AdminFlags.vue

@@ -15,13 +15,20 @@ q-page.admin-flags
         target='_blank'
         target='_blank'
         type='a'
         type='a'
         )
         )
+      q-btn.q-mr-sm.acrylic-btn(
+        icon='las la-redo-alt'
+        flat
+        color='secondary'
+        :loading='state.loading > 0'
+        @click='load'
+        )
       q-btn(
       q-btn(
         unelevated
         unelevated
         icon='fa-solid fa-check'
         icon='fa-solid fa-check'
         :label='t(`common.actions.apply`)'
         :label='t(`common.actions.apply`)'
         color='secondary'
         color='secondary'
         @click='save'
         @click='save'
-        :loading='loading'
+        :loading='state.loading > 0'
       )
       )
   q-separator(inset)
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
   .row.q-pa-md.q-col-gutter-md
@@ -39,44 +46,60 @@ q-page.admin-flags
         q-item(tag='label')
         q-item(tag='label')
           blueprint-icon(icon='flag-filled')
           blueprint-icon(icon='flag-filled')
           q-item-section
           q-item-section
-            q-item-label {{t(`admin.flags.ldapdebug.label`)}}
-            q-item-label(caption) {{t(`admin.flags.ldapdebug.hint`)}}
+            q-item-label {{t(`admin.flags.experimental.label`)}}
+            q-item-label(caption) {{t(`admin.flags.experimental.hint`)}}
           q-item-section(avatar)
           q-item-section(avatar)
             q-toggle(
             q-toggle(
-              v-model='flags.ldapdebug'
-              color='primary'
+              v-model='state.flags.experimental'
+              color='negative'
               checked-icon='las la-check'
               checked-icon='las la-check'
               unchecked-icon='las la-times'
               unchecked-icon='las la-times'
-              :aria-label='t(`admin.flags.ldapdebug.label`)'
+              :aria-label='t(`admin.flags.experimental.label`)'
               )
               )
         q-separator.q-my-sm(inset)
         q-separator.q-my-sm(inset)
         q-item(tag='label')
         q-item(tag='label')
           blueprint-icon(icon='flag-filled')
           blueprint-icon(icon='flag-filled')
           q-item-section
           q-item-section
-            q-item-label {{t(`admin.flags.sqllog.label`)}}
-            q-item-label(caption) {{t(`admin.flags.sqllog.hint`)}}
+            q-item-label {{t(`admin.flags.authDebug.label`)}}
+            q-item-label(caption) {{t(`admin.flags.authDebug.hint`)}}
           q-item-section(avatar)
           q-item-section(avatar)
             q-toggle(
             q-toggle(
-              v-model='flags.sqllog'
-              color='primary'
+              v-model='state.flags.authDebug'
+              color='negative'
               checked-icon='las la-check'
               checked-icon='las la-check'
               unchecked-icon='las la-times'
               unchecked-icon='las la-times'
-              :aria-label='t(`admin.flags.sqllog.label`)'
+              :aria-label='t(`admin.flags.authDebug.label`)'
               )
               )
-      q-card.shadow-1.q-py-sm.q-mt-md
+        q-separator.q-my-sm(inset)
         q-item(tag='label')
         q-item(tag='label')
-          blueprint-icon(icon='heart-outline')
+          blueprint-icon(icon='flag-filled')
           q-item-section
           q-item-section
-            q-item-label {{t(`admin.flags.hidedonatebtn.label`)}}
-            q-item-label(caption) {{t(`admin.flags.hidedonatebtn.hint`)}}
+            q-item-label {{t(`admin.flags.sqlLog.label`)}}
+            q-item-label(caption) {{t(`admin.flags.sqlLog.hint`)}}
           q-item-section(avatar)
           q-item-section(avatar)
             q-toggle(
             q-toggle(
-              v-model='flags.hidedonatebtn'
-              color='primary'
+              v-model='state.flags.sqlLog'
+              color='negative'
               checked-icon='las la-check'
               checked-icon='las la-check'
               unchecked-icon='las la-times'
               unchecked-icon='las la-times'
-              :aria-label='t(`admin.flags.hidedonatebtn.label`)'
+              :aria-label='t(`admin.flags.sqlLog.label`)'
               )
               )
+      q-card.shadow-1.q-py-sm.q-mt-md
+        q-item
+          blueprint-icon(icon='administrative-tools')
+          q-item-section
+            q-item-label {{t(`admin.flags.advanced.label`)}}
+            q-item-label(caption) {{t(`admin.flags.advanced.hint`)}}
+          q-item-section(avatar)
+            q-btn(
+              :label='t(`common.actions.edit`)'
+              unelevated
+              icon='las la-code'
+              color='primary'
+              text-color='white'
+              @click=''
+              disabled
+            )
 
 
     .col-12.col-lg-5.gt-md
     .col-12.col-lg-5.gt-md
       .q-pa-md.text-center
       .q-pa-md.text-center
@@ -85,12 +108,13 @@ q-page.admin-flags
 
 
 <script setup>
 <script setup>
 import gql from 'graphql-tag'
 import gql from 'graphql-tag'
-import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
-import { transform } from 'lodash-es'
+import { defineAsyncComponent, onMounted, reactive, ref } from 'vue'
+import { cloneDeep, omit } from 'lodash-es'
 import { useMeta, useQuasar } from 'quasar'
 import { useMeta, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 
 
 import { useSiteStore } from 'src/stores/site'
 import { useSiteStore } from 'src/stores/site'
+import { useFlagsStore } from 'src/stores/flags'
 
 
 // QUASAR
 // QUASAR
 
 
@@ -98,6 +122,7 @@ const $q = useQuasar()
 
 
 // STORES
 // STORES
 
 
+const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const siteStore = useSiteStore()
 
 
 // I18N
 // I18N
@@ -110,67 +135,76 @@ useMeta({
   title: t('admin.flags.title')
   title: t('admin.flags.title')
 })
 })
 
 
-const loading = ref(false)
-const flags = reactive({
-  ldapdebug: false,
-  sqllog: false,
-  hidedonatebtn: false
+// DATA
+
+const state = reactive({
+  loading: 0,
+  flags: {
+    experimental: false,
+    authDebug: false,
+    sqlLog: false
+  }
 })
 })
 
 
-const save = async () => {
+// METHODS
 
 
+async function load () {
+  state.loading++
+  $q.loading.show()
+  await flagsStore.load()
+  state.flags = omit(cloneDeep(flagsStore.$state), ['loaded'])
+  $q.loading.hide()
+  state.loading--
 }
 }
 
 
-// methods: {
-//   async save () {
-//     try {
-//       await this.$apollo.mutate({
-//         mutation: gql`
-//           mutation updateFlags (
-//             $flags: [SystemFlagInput]!
-//           ) {
-//             updateSystemFlags(
-//               flags: $flags
-//             ) {
-//               status {
-//                 succeeded
-//                 slug
-//                 message
-//               }
-//             }
-//           }
-//         `,
-//         variables: {
-//           flags: _transform(this.flags, (result, value, key) => {
-//             result.push({ key, value })
-//           }, [])
-//         },
-//         watchLoading (isLoading) {
-//           this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-flags-update')
-//         }
-//       })
-//       this.$store.commit('showNotification', {
-//         style: 'success',
-//         message: 'Flags applied successfully.',
-//         icon: 'check'
-//       })
-//     } catch (err) {
-//       this.$store.commit('pushGraphError', err)
-//     }
-//   }
-// }
-// apollo: {
-//   flags: {
-//     query: gql``,
-//     fetchPolicy: 'network-only',
-//     update: (data) => _transform(data.system.flags, (result, row) => {
-//       _set(result, row.key, row.value)
-//     }, {}),
-//     watchLoading (isLoading) {
-//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-flags-refresh')
-//     }
-//   }
-// }
+async function save () {
+  if (state.loading > 0) { return }
+
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation updateFlags (
+          $flags: JSON!
+        ) {
+          updateSystemFlags(
+            flags: $flags
+          ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        flags: state.flags
+      }
+    })
+    if (resp?.data?.updateSystemFlags?.operation?.succeeded) {
+      load()
+      $q.notify({
+        type: 'positive',
+        message: t('admin.flags.saveSuccess')
+      })
+    } else {
+      throw new Error(resp?.data?.updateSystemFlags?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(async () => {
+  load()
+})
+
 </script>
 </script>
 
 
 <style lang='scss'>
 <style lang='scss'>

+ 1 - 1
ux/src/pages/AdminScheduler.vue

@@ -577,7 +577,7 @@ async function cancelJob (jobId) {
       }
       }
     })
     })
     if (resp?.data?.cancelJob?.operation?.succeeded) {
     if (resp?.data?.cancelJob?.operation?.succeeded) {
-      this.load()
+      load()
       $q.notify({
       $q.notify({
         type: 'positive',
         type: 'positive',
         message: t('admin.scheduler.cancelJobSuccess')
         message: t('admin.scheduler.cancelJobSuccess')

+ 36 - 0
ux/src/stores/flags.js

@@ -0,0 +1,36 @@
+import { defineStore } from 'pinia'
+import gql from 'graphql-tag'
+
+export const useFlagsStore = defineStore('flags', {
+  state: () => ({
+    loaded: false,
+    experimental: false
+  }),
+  getters: {},
+  actions: {
+    async load () {
+      try {
+        const resp = await APOLLO_CLIENT.query({
+          query: gql`
+            query getFlag {
+              systemFlags
+            }
+          `,
+          fetchPolicy: 'network-only'
+        })
+        const systemFlags = resp.data.systemFlags
+        if (systemFlags) {
+          this.$patch({
+            ...systemFlags,
+            loaded: true
+          })
+        } else {
+          throw new Error('Could not fetch system flags.')
+        }
+      } catch (err) {
+        console.warn(err.networkError?.result ?? err.message)
+        throw err
+      }
+    }
+  }
+})

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff