Browse Source

feat: rename folder + fileman improvements

Nicolas Giard 2 years ago
parent
commit
c377eca6c7

+ 86 - 2
server/graph/resolvers/tree.js

@@ -88,6 +88,18 @@ module.exports = {
           childrenCount: 0
           childrenCount: 0
         }
         }
       }))
       }))
+    },
+    async folderById (obj, args, context) {
+      const folder = await WIKI.db.knex('tree')
+        .select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth'))
+        .where('id', args.id)
+        .first()
+
+      return {
+        ...folder,
+        folderPath: folder.folderPath.replaceAll('.', '/').replaceAll('_', '-'),
+        childrenCount: 0
+      }
     }
     }
   },
   },
   Mutation: {
   Mutation: {
@@ -96,6 +108,8 @@ module.exports = {
      */
      */
     async createFolder (obj, args, context) {
     async createFolder (obj, args, context) {
       try {
       try {
+        WIKI.logger.debug(`Creating new folder ${args.pathName}...`)
+
         // Get parent path
         // Get parent path
         let parentPath = ''
         let parentPath = ''
         if (args.parentId) {
         if (args.parentId) {
@@ -128,6 +142,7 @@ module.exports = {
         }
         }
 
 
         // Create folder
         // Create folder
+        WIKI.logger.debug(`Creating new folder ${args.pathName} at path /${parentPath}...`)
         await WIKI.db.knex('tree').insert({
         await WIKI.db.knex('tree').insert({
           folderPath: parentPath,
           folderPath: parentPath,
           fileName: args.pathName,
           fileName: args.pathName,
@@ -139,6 +154,74 @@ module.exports = {
           operation: graphHelper.generateSuccess('Folder created successfully')
           operation: graphHelper.generateSuccess('Folder created successfully')
         }
         }
       } catch (err) {
       } catch (err) {
+        WIKI.logger.debug(`Failed to create folder: ${err.message}`)
+        return graphHelper.generateError(err)
+      }
+    },
+    /**
+     * RENAME FOLDER
+     */
+    async renameFolder (obj, args, context) {
+      try {
+        // Get folder
+        const folder = await WIKI.db.knex('tree').where('id', args.folderId).first()
+        WIKI.logger.debug(`Renaming folder ${folder.id} path to ${args.pathName}...`)
+
+        // Validate path name
+        if (!rePathName.test(args.pathName)) {
+          throw new Error('ERR_INVALID_PATH_NAME')
+        }
+
+        // Validate title
+        if (!reTitle.test(args.title)) {
+          throw new Error('ERR_INVALID_TITLE')
+        }
+
+        if (args.pathName !== folder.fileName) {
+          // Check for collision
+          const existingFolder = await WIKI.db.knex('tree')
+            .whereNot('id', folder.id)
+            .andWhere({
+              siteId: folder.siteId,
+              folderPath: folder.folderPath,
+              fileName: args.pathName
+            }).first()
+          if (existingFolder) {
+            throw new Error('ERR_FOLDER_ALREADY_EXISTS')
+          }
+
+          // Build new paths
+          const oldFolderPath = (folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName).replaceAll('-', '_')
+          const newFolderPath = (folder.folderPath ? `${folder.folderPath}.${args.pathName}` : args.pathName).replaceAll('-', '_')
+
+          // Update children nodes
+          WIKI.logger.debug(`Updating parent path of children nodes from ${oldFolderPath} to ${newFolderPath} ...`)
+          await WIKI.db.knex('tree').where('siteId', folder.siteId).andWhere('folderPath', oldFolderPath).update({
+            folderPath: newFolderPath
+          })
+          await WIKI.db.knex('tree').where('siteId', folder.siteId).andWhere('folderPath', '<@', oldFolderPath).update({
+            folderPath: WIKI.db.knex.raw(`'${newFolderPath}' || subpath(tree."folderPath", nlevel('${newFolderPath}'))`)
+          })
+
+          // Rename the folder itself
+          await WIKI.db.knex('tree').where('id', folder.id).update({
+            fileName: args.pathName,
+            title: args.title
+          })
+        } else {
+          // Update the folder title only
+          await WIKI.db.knex('tree').where('id', folder.id).update({
+            title: args.title
+          })
+        }
+
+        WIKI.logger.debug(`Renamed folder ${folder.id} successfully.`)
+
+        return {
+          operation: graphHelper.generateSuccess('Folder renamed successfully')
+        }
+      } catch (err) {
+        WIKI.logger.debug(`Failed to rename folder ${args.folderId}: ${err.message}`)
         return graphHelper.generateError(err)
         return graphHelper.generateError(err)
       }
       }
     },
     },
@@ -153,7 +236,7 @@ module.exports = {
         WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`)
         WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`)
 
 
         // Delete all children
         // Delete all children
-        const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '~', `${folderPath}.*`).del().returning(['id', 'type'])
+        const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '<@', folderPath).del().returning(['id', 'type'])
 
 
         // Delete folders
         // Delete folders
         const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id)
         const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id)
@@ -179,12 +262,13 @@ module.exports = {
 
 
         // Delete the folder itself
         // Delete the folder itself
         await WIKI.db.knex('tree').where('id', folder.id).del()
         await WIKI.db.knex('tree').where('id', folder.id).del()
-        WIKI.logger.debug(`Deleting folder ${folder.id} successfully.`)
+        WIKI.logger.debug(`Deleted folder ${folder.id} successfully.`)
 
 
         return {
         return {
           operation: graphHelper.generateSuccess('Folder deleted successfully')
           operation: graphHelper.generateSuccess('Folder deleted successfully')
         }
         }
       } catch (err) {
       } catch (err) {
+        WIKI.logger.debug(`Failed to delete folder ${args.folderId}: ${err.message}`)
         return graphHelper.generateError(err)
         return graphHelper.generateError(err)
       }
       }
     }
     }

+ 5 - 2
server/graph/schemas/tree.graphql

@@ -15,6 +15,9 @@ extend type Query {
     depth: Int
     depth: Int
     includeAncestors: Boolean
     includeAncestors: Boolean
     ): [TreeItem]
     ): [TreeItem]
+  folderById(
+    id: UUID!
+  ): TreeItemFolder
 }
 }
 
 
 extend type Mutation {
 extend type Mutation {
@@ -39,8 +42,8 @@ extend type Mutation {
   ): DefaultResponse
   ): DefaultResponse
   renameFolder(
   renameFolder(
     folderId: UUID!
     folderId: UUID!
-    pathName: String
-    title: String
+    pathName: String!
+    title: String!
   ): DefaultResponse
   ): DefaultResponse
 }
 }
 
 

+ 1 - 0
ux/public/_assets/icons/fluent-rename.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="PuVtuXTbHVUsxZgps56lha" x1="4" x2="44" y1="24" y2="24" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#50e6ff"/><stop offset=".55" stop-color="#50e6ff"/><stop offset=".58" stop-color="#4fe3fc"/><stop offset=".601" stop-color="#4edaf4"/><stop offset=".62" stop-color="#4acae7"/><stop offset=".637" stop-color="#46b4d3"/><stop offset=".64" stop-color="#45b0d0"/><stop offset=".71" stop-color="#45b0d0"/><stop offset=".713" stop-color="#46b4d3"/><stop offset=".73" stop-color="#4acae7"/><stop offset=".749" stop-color="#4edaf4"/><stop offset=".77" stop-color="#4fe3fc"/><stop offset=".8" stop-color="#50e6ff"/><stop offset="1" stop-color="#50e6ff"/></linearGradient><path fill="url(#PuVtuXTbHVUsxZgps56lha)" d="M4,16v16c0,1.105,0.895,2,2,2h36c1.105,0,2-0.895,2-2V16c0-1.105-0.895-2-2-2H6 C4.895,14,4,14.895,4,16z"/><path fill="#057093" d="M38,44h-1c-4.418,0-8-3.582-8-8V12c0-4.418,3.582-8,8-8h1c0.552,0,1,0.448,1,1v2 c0,0.552-0.448,1-1,1h-1c-2.209,0-4,1.791-4,4v24c0,2.209,1.791,4,4,4h1c0.552,0,1,0.448,1,1v2C39,43.552,38.552,44,38,44z"/><path fill="#057093" d="M24,44h1c4.418,0,8-3.582,8-8V12c0-4.418-3.582-8-8-8h-1c-0.552,0-1,0.448-1,1v2 c0,0.552,0.448,1,1,1h1c2.209,0,4,1.791,4,4v24c0,2.209-1.791,4-4,4h-1c-0.552,0-1,0.448-1,1v2C23,43.552,23.448,44,24,44z"/></svg>

+ 203 - 95
ux/src/components/FileManager.vue

@@ -37,34 +37,44 @@ q-layout.fileman(view='hHh lpR lFr', container)
         )
         )
         q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}}
         q-tooltip(anchor='bottom middle', self='top middle') {{t(`common.actions.close`)}}
   q-drawer.fileman-left(:model-value='true', :width='350')
   q-drawer.fileman-left(:model-value='true', :width='350')
-    .q-px-md.q-pb-sm
-      tree(
-        ref='treeComp'
-        :nodes='state.treeNodes'
-        :roots='state.treeRoots'
-        v-model:selected='state.currentFolderId'
-        @lazy-load='treeLazyLoad'
-        :use-lazy-load='true'
-        @context-action='treeContextAction'
-        :display-mode='state.displayMode'
+    q-scroll-area(
+      :thumb-style='thumbStyle'
+      :bar-style='barStyle'
+      style='height: 100%;'
       )
       )
-  q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right')
-    .q-pa-md
-      template(v-if='currentFileDetails')
-        q-img.rounded-borders.q-mb-md(
-          src='/_assets/illustrations/fileman-page.svg'
-          width='100%'
-          fit='cover'
-          :ratio='16/10'
-          no-spinner
+      .q-px-md.q-pb-sm
+        tree(
+          ref='treeComp'
+          :nodes='state.treeNodes'
+          :roots='state.treeRoots'
+          v-model:selected='state.currentFolderId'
+          @lazy-load='treeLazyLoad'
+          :use-lazy-load='true'
+          @context-action='treeContextAction'
+          :display-mode='state.displayMode'
         )
         )
-        .fileman-details-row(
-          v-for='item of currentFileDetails.items'
+  q-drawer.fileman-right(:model-value='$q.screen.gt.md', :width='350', side='right')
+    q-scroll-area(
+      :thumb-style='thumbStyle'
+      :bar-style='barStyle'
+      style='height: 100%;'
+      )
+      .q-pa-md
+        template(v-if='currentFileDetails')
+          q-img.rounded-borders.q-mb-md(
+            src='/_assets/illustrations/fileman-page.svg'
+            width='100%'
+            fit='cover'
+            :ratio='16/10'
+            no-spinner
           )
           )
-          label {{item.label}}
-          span {{item.value}}
+          .fileman-details-row(
+            v-for='item of currentFileDetails.items'
+            )
+            label {{item.label}}
+            span {{item.value}}
   q-page-container
   q-page-container
-    q-page.fileman-center
+    q-page.fileman-center.column
       //- TOOLBAR -----------------------------------------------------
       //- TOOLBAR -----------------------------------------------------
       q-toolbar.fileman-toolbar
       q-toolbar.fileman-toolbar
         template(v-if='state.isUploading')
         template(v-if='state.isUploading')
@@ -182,68 +192,79 @@ q-layout.fileman(view='hHh lpR lFr', container)
             icon='las la-cloud-upload-alt'
             icon='las la-cloud-upload-alt'
             @click='uploadFile'
             @click='uploadFile'
             )
             )
-      .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(
-          v-for='item of files'
-          :key='item.id'
-          clickable
-          active-class='active'
-          :active='item.id === state.currentFileId'
-          @click.native='selectItem(item)'
-          @dblclick.native='openItem(item)'
-          )
-          q-item-section.fileman-filelist-icon(avatar)
-            q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
-          q-item-section.fileman-filelist-label
-            q-item-label {{item.title}}
-            q-item-label(caption, v-if='!state.isCompact') {{item.caption}}
-          q-item-section.fileman-filelist-side(side, v-if='item.side')
-            .text-caption {{item.side}}
-          //- RIGHT-CLICK MENU
-          q-menu(
-            touch-position
-            context-menu
-            auto-close
-            transition-show='jump-down'
-            transition-hide='jump-up'
+
+      .row(style='flex: 1 1 100%;')
+        .col
+          q-scroll-area(
+            :thumb-style='thumbStyle'
+            :bar-style='barStyle'
+            style='height: 100%;'
             )
             )
-            q-card.q-pa-sm
-              q-list(dense, style='min-width: 150px;')
-                q-item(clickable, v-if='item.type === `page`')
-                  q-item-section(side)
-                    q-icon(name='las la-edit', color='orange')
-                  q-item-section Edit
-                q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)')
-                  q-item-section(side)
-                    q-icon(name='las la-eye', color='primary')
-                  q-item-section View
-                q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)')
-                  q-item-section(side)
-                    q-icon(name='las la-clipboard', color='primary')
-                  q-item-section Copy URL
-                q-item(clickable)
-                  q-item-section(side)
-                    q-icon(name='las la-copy', color='teal')
-                  q-item-section Duplicate...
-                q-item(clickable)
-                  q-item-section(side)
-                    q-icon(name='las la-redo', color='teal')
-                  q-item-section Rename...
-                q-item(clickable)
-                  q-item-section(side)
-                    q-icon(name='las la-arrow-right', color='teal')
-                  q-item-section Move to...
-                q-item(clickable, @click='delItem(item)')
-                  q-item-section(side)
-                    q-icon(name='las la-trash-alt', color='negative')
-                  q-item-section.text-negative Delete
+            .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-folder-open', size='sm')
+                span This folder is empty.
+            q-list.fileman-filelist(
+              v-else
+              :class='state.isCompact && `is-compact`'
+              )
+              q-item(
+                v-for='item of files'
+                :key='item.id'
+                clickable
+                active-class='active'
+                :active='item.id === state.currentFileId'
+                @click.native='selectItem(item)'
+                @dblclick.native='openItem(item)'
+                )
+                q-item-section.fileman-filelist-icon(avatar)
+                  q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
+                q-item-section.fileman-filelist-label
+                  q-item-label {{usePathTitle ? item.fileName : item.title}}
+                  q-item-label(caption, v-if='!state.isCompact') {{item.caption}}
+                q-item-section.fileman-filelist-side(side, v-if='item.side')
+                  .text-caption {{item.side}}
+                //- RIGHT-CLICK MENU
+                q-menu(
+                  touch-position
+                  context-menu
+                  auto-close
+                  transition-show='jump-down'
+                  transition-hide='jump-up'
+                  )
+                  q-card.q-pa-sm
+                    q-list(dense, style='min-width: 150px;')
+                      q-item(clickable, v-if='item.type === `page`')
+                        q-item-section(side)
+                          q-icon(name='las la-edit', color='orange')
+                        q-item-section Edit
+                      q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)')
+                        q-item-section(side)
+                          q-icon(name='las la-eye', color='primary')
+                        q-item-section View
+                      q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)')
+                        q-item-section(side)
+                          q-icon(name='las la-clipboard', color='primary')
+                        q-item-section Copy URL
+                      q-item(clickable)
+                        q-item-section(side)
+                          q-icon(name='las la-copy', color='teal')
+                        q-item-section Duplicate...
+                      q-item(clickable, @click='renameItem(item)')
+                        q-item-section(side)
+                          q-icon(name='las la-redo', color='teal')
+                        q-item-section Rename...
+                      q-item(clickable)
+                        q-item-section(side)
+                          q-icon(name='las la-arrow-right', color='teal')
+                        q-item-section Move to...
+                      q-item(clickable, @click='delItem(item)')
+                        q-item-section(side)
+                          q-icon(name='las la-trash-alt', color='negative')
+                        q-item-section.text-negative Delete
   q-footer
   q-footer
     q-bar.fileman-path
     q-bar.fileman-path
       small.text-caption.text-grey-7 {{folderPath}}
       small.text-caption.text-grey-7 {{folderPath}}
@@ -259,7 +280,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
 
 
 <script setup>
 <script setup>
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
-import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
+import { computed, defineAsyncComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'
 import { filesize } from 'filesize'
 import { filesize } from 'filesize'
 import { useQuasar } from 'quasar'
 import { useQuasar } from 'quasar'
 import { DateTime } from 'luxon'
 import { DateTime } from 'luxon'
@@ -278,6 +299,7 @@ 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'
 import FolderDeleteDialog from 'src/components/FolderDeleteDialog.vue'
+import FolderRenameDialog from 'src/components/FolderRenameDialog.vue'
 
 
 // QUASAR
 // QUASAR
 
 
@@ -301,6 +323,7 @@ const { t } = useI18n()
 
 
 const state = reactive({
 const state = reactive({
   loading: 0,
   loading: 0,
+  isFetching: false,
   search: '',
   search: '',
   currentFolderId: null,
   currentFolderId: null,
   currentFileId: null,
   currentFileId: null,
@@ -316,6 +339,19 @@ const state = reactive({
   fileListLoading: false
   fileListLoading: false
 })
 })
 
 
+const thumbStyle = {
+  right: '2px',
+  borderRadius: '5px',
+  backgroundColor: '#000',
+  width: '5px',
+  opacity: 0.15
+}
+const barStyle = {
+  backgroundColor: '#FAFAFA',
+  width: '9px',
+  opacity: 1
+}
+
 // REFS
 // REFS
 
 
 const fileIpt = ref(null)
 const fileIpt = ref(null)
@@ -332,6 +368,8 @@ const folderPath = computed(() => {
   }
   }
 })
 })
 
 
+const usePathTitle = computed(() => state.displayMode === 'path')
+
 const filteredFiles = computed(() => {
 const filteredFiles = computed(() => {
   if (state.search) {
   if (state.search) {
     const fuse = new Fuse(state.fileList, {
     const fuse = new Fuse(state.fileList, {
@@ -348,7 +386,6 @@ const filteredFiles = computed(() => {
 
 
 const files = computed(() => {
 const files = computed(() => {
   return filteredFiles.value.filter(f => {
   return filteredFiles.value.filter(f => {
-    console.info(f)
     // -> Show Folders Filter
     // -> Show Folders Filter
     if (f.type === 'folder' && !state.shouldShowFolders) {
     if (f.type === 'folder' && !state.shouldShowFolders) {
       return false
       return false
@@ -453,6 +490,8 @@ async function treeLazyLoad (nodeId, { done, fail }) {
 }
 }
 
 
 async function loadTree (parentId, types) {
 async function loadTree (parentId, types) {
+  if (state.isFetching) { return }
+  state.isFetching = true
   if (!parentId) {
   if (!parentId) {
     parentId = null
     parentId = null
   }
   }
@@ -517,13 +556,11 @@ async function loadTree (parentId, types) {
         switch (item.__typename) {
         switch (item.__typename) {
           case 'TreeItemFolder': {
           case 'TreeItemFolder': {
             // -> Tree Nodes
             // -> Tree Nodes
-            if (!state.treeNodes[item.id]) {
-              state.treeNodes[item.id] = {
-                folderPath: item.folderPath,
-                fileName: item.fileName,
-                title: item.title,
-                children: state.treeNodes[item.id]?.children ?? []
-              }
+            state.treeNodes[item.id] = {
+              folderPath: item.folderPath,
+              fileName: item.fileName,
+              title: item.title,
+              children: state.treeNodes[item.id]?.children ?? []
             }
             }
 
 
             // -> Set Ancestors / Tree Roots
             // -> Set Ancestors / Tree Roots
@@ -596,6 +633,7 @@ async function loadTree (parentId, types) {
   if (parentId) {
   if (parentId) {
     treeComp.value.setLoaded(parentId)
     treeComp.value.setLoaded(parentId)
   }
   }
+  state.isFetching = false
 }
 }
 
 
 function treeContextAction (nodeId, action) {
 function treeContextAction (nodeId, action) {
@@ -604,6 +642,10 @@ function treeContextAction (nodeId, action) {
       newFolder(nodeId)
       newFolder(nodeId)
       break
       break
     }
     }
+    case 'rename': {
+      renameFolder(nodeId)
+      break
+    }
     case 'del': {
     case 'del': {
       delFolder(nodeId)
       delFolder(nodeId)
       break
       break
@@ -626,6 +668,18 @@ function newFolder (parentId) {
   })
   })
 }
 }
 
 
+function renameFolder (folderId) {
+  $q.dialog({
+    component: FolderRenameDialog,
+    componentProps: {
+      folderId
+    }
+  }).onOk(() => {
+    treeComp.value.resetLoaded()
+    loadTree(folderId)
+  })
+}
+
 function delFolder (folderId, mustReload = false) {
 function delFolder (folderId, mustReload = false) {
   $q.dialog({
   $q.dialog({
     component: FolderDeleteDialog,
     component: FolderDeleteDialog,
@@ -654,6 +708,21 @@ function reloadFolder (folderId) {
   treeComp.value.resetLoaded()
   treeComp.value.resetLoaded()
 }
 }
 
 
+// PAGE METHODS
+// --------------------------------------
+
+function delPage (pageId, pageName) {
+  $q.dialog({
+    component: defineAsyncComponent(() => import('src/components/PageDeleteDialog.vue')),
+    componentProps: {
+      pageId,
+      pageName
+    }
+  }).onOk(() => {
+    loadTree(state.currentFolderId, null)
+  })
+}
+
 // --------------------------------------
 // --------------------------------------
 // UPLOAD METHODS
 // UPLOAD METHODS
 // --------------------------------------
 // --------------------------------------
@@ -796,12 +865,34 @@ async function copyItemURL (item) {
   }
   }
 }
 }
 
 
+function renameItem (item) {
+  console.info(item)
+  switch (item.type) {
+    case 'folder': {
+      renameFolder(item.id)
+      break
+    }
+    case 'page': {
+      // TODO: Rename page
+      break
+    }
+    case 'asset': {
+      // TODO: Rename asset
+      break
+    }
+  }
+}
+
 function delItem (item) {
 function delItem (item) {
   switch (item.type) {
   switch (item.type) {
     case 'folder': {
     case 'folder': {
       delFolder(item.id, true)
       delFolder(item.id, true)
       break
       break
     }
     }
+    case 'page': {
+      delPage(item.id, item.title)
+      break
+    }
   }
   }
 }
 }
 
 
@@ -825,6 +916,7 @@ onMounted(() => {
   }
   }
 
 
   &-center {
   &-center {
+
     @at-root .body--light & {
     @at-root .body--light & {
       background-color: #FFF;
       background-color: #FFF;
     }
     }
@@ -860,6 +952,10 @@ onMounted(() => {
     }
     }
   }
   }
 
 
+  &-main {
+    height: 100%;
+  }
+
   &-emptylist {
   &-emptylist {
     padding: 16px;
     padding: 16px;
     font-style: italic;
     font-style: italic;
@@ -878,7 +974,7 @@ onMounted(() => {
     padding: 8px 12px;
     padding: 8px 12px;
 
 
     > .q-item {
     > .q-item {
-      padding: 8px 6px;
+      padding: 4px 6px;
       border-radius: 8px;
       border-radius: 8px;
 
 
       &.active {
       &.active {
@@ -894,6 +990,18 @@ onMounted(() => {
         }
         }
       }
       }
     }
     }
+
+    &.is-compact {
+      > .q-item {
+        padding: 0 6px;
+        min-height: 36px;
+      }
+
+      .fileman-filelist-icon {
+        padding-right: 6px;
+        min-width: 0;
+      }
+    }
   }
   }
   &-details-row {
   &-details-row {
     display: flex;
     display: flex;

+ 226 - 0
ux/src/components/FolderRenameDialog.vue

@@ -0,0 +1,226 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 650px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-rename.svg', left, size='sm')
+      span {{t(`fileman.folderRename`)}}
+    q-form.q-py-sm(ref='renameFolderForm', @submit='rename')
+      q-item
+        blueprint-icon(icon='folder')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.title'
+            dense
+            :rules='titleValidation'
+            hide-bottom-space
+            :label='t(`fileman.folderTitle`)'
+            :aria-label='t(`fileman.folderTitle`)'
+            lazy-rules='ondemand'
+            autofocus
+            ref='iptTitle'
+            @keyup.enter='rename'
+            )
+      q-item
+        blueprint-icon.self-start(icon='file-submodule')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.path'
+            dense
+            :rules='pathValidation'
+            hide-bottom-space
+            :label='t(`fileman.folderFileName`)'
+            :aria-label='t(`fileman.folderFileName`)'
+            :hint='t(`fileman.folderFileNameHint`)'
+            lazy-rules='ondemand'
+            @focus='state.pathDirty = true'
+            @keyup.enter='rename'
+            )
+    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.rename`)'
+        color='primary'
+        padding='xs md'
+        @click='rename'
+        :loading='state.loading > 0'
+        )
+    q-inner-loading(:showing='state.loading > 0')
+      q-spinner(color='accent', size='lg')
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { onMounted, reactive, ref, watch } from 'vue'
+import slugify from 'slugify'
+
+import { useSiteStore } from 'src/stores/site'
+
+// PROPS
+
+const props = defineProps({
+  folderId: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// STORES
+
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  path: '',
+  title: '',
+  pathDirty: false,
+  loading: false
+})
+
+// REFS
+
+const renameFolderForm = ref(null)
+const iptTitle = ref(null)
+
+// VALIDATION RULES
+
+const titleValidation = [
+  val => val.length > 0 || t('fileman.folderTitleMissing'),
+  val => /^[^<>"]+$/.test(val) || t('fileman.folderTitleInvalidChars')
+]
+
+const pathValidation = [
+  val => val.length > 0 || t('fileman.folderFileNameMissing'),
+  val => /^[a-z0-9-]+$/.test(val) || t('fileman.folderFileNameInvalid')
+]
+
+// WATCHERS
+
+watch(() => state.title, (newValue) => {
+  if (state.pathDirty && !state.path) {
+    state.pathDirty = false
+  }
+  if (!state.pathDirty) {
+    state.path = slugify(newValue, { lower: true, strict: true })
+  }
+})
+
+// METHODS
+
+async function rename () {
+  state.loading++
+  try {
+    const isFormValid = await renameFolderForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('fileman.renameFolderInvalidData'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation renameFolder (
+          $folderId: UUID!
+          $pathName: String!
+          $title: String!
+          ) {
+          renameFolder (
+            folderId: $folderId
+            pathName: $pathName
+            title: $title
+            ) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        folderId: props.folderId,
+        pathName: state.path,
+        title: state.title
+      }
+    })
+    if (resp?.data?.renameFolder?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('fileman.renameFolderSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.renameFolder?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(async () => {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query fetchFolderForRename (
+          $id: UUID!
+          ) {
+          folderById (
+            id: $id
+            ) {
+            id
+            folderPath
+            fileName
+            title
+          }
+        }
+      `,
+      variables: {
+        id: props.folderId
+      }
+    })
+    if (resp?.data?.folderById?.id !== props.folderId) {
+      throw new Error('Failed to fetch folder data.')
+    }
+    state.path = resp.data.folderById.fileName
+    state.title = resp.data.folderById.title
+    state.pathDirty = true
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+    onDialogCancel()
+  }
+  state.loading--
+})
+</script>

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

@@ -282,7 +282,7 @@ async function loadTree (parentId, types) {
               title
               title
               createdAt
               createdAt
               updatedAt
               updatedAt
-              pageEditor
+              editor
             }
             }
           }
           }
         }
         }

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

@@ -1611,5 +1611,8 @@
   "admin.flags.advanced.label": "Custom Configuration",
   "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.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.",
   "admin.flags.saveSuccess": "Flags have been updated successfully.",
-  "fileman.copyURLSuccess": "URL has been copied to the clipboard."
+  "fileman.copyURLSuccess": "URL has been copied to the clipboard.",
+  "fileman.folderRename": "Rename Folder",
+  "fileman.renameFolderInvalidData": "One or more fields are invalid.",
+  "fileman.renameFolderSuccess": "Folder renamed successfully."
 }
 }