瀏覽代碼

feat: file manager improvements + tree db model

Nicolas Giard 2 年之前
父節點
當前提交
714aa1eb0f

+ 53 - 132
server/graph/resolvers/tree.js

@@ -12,6 +12,9 @@ const reTitle = /^[^<>"]+$/
 
 module.exports = {
   Query: {
+    /**
+     * FETCH TREE
+     */
     async tree (obj, args, context, info) {
       // Offset
       const offset = args.offset || 0
@@ -20,8 +23,8 @@ module.exports = {
       }
 
       // Limit
-      const limit = args.limit || 100
-      if (limit < 1 || limit > 100) {
+      const limit = args.limit || 1000
+      if (limit < 1 || limit > 1000) {
         throw new Error('Invalid Limit')
       }
 
@@ -53,17 +56,27 @@ module.exports = {
         .select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth'))
         .where(builder => {
           builder.where('folderPath', '~', folderPathCondition)
+          // -> Include ancestors
           if (args.includeAncestors) {
             const parentPathParts = parentPath.split('.')
             for (let i = 1; i <= parentPathParts.length; i++) {
               builder.orWhere({
                 folderPath: _.dropRight(parentPathParts, i).join('.'),
-                fileName: _.nth(parentPathParts, i * -1)
+                fileName: _.nth(parentPathParts, i * -1),
+                type: 'folder'
               })
             }
           }
+          // -> Include root items
+          if (args.includeRootItems) {
+            builder.orWhere({
+              folderPath: '',
+              type: 'folder'
+            })
+          }
         })
         .andWhere(builder => {
+          // -> Limit to specific types
           if (args.types && args.types.length > 0) {
             builder.whereIn('type', args.types)
           }
@@ -85,20 +98,52 @@ module.exports = {
         createdAt: item.createdAt,
         updatedAt: item.updatedAt,
         ...(item.type === 'folder') && {
-          childrenCount: 0
+          childrenCount: item.meta?.children || 0
         }
       }))
     },
+    /**
+     * FETCH SINGLE FOLDER BY ID
+     */
     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()
 
+      if (!folder) {
+        throw new Error('ERR_FOLDER_NOT_EXIST')
+      }
+
       return {
         ...folder,
         folderPath: folder.folderPath.replaceAll('.', '/').replaceAll('_', '-'),
-        childrenCount: 0
+        childrenCount: folder.meta?.children || 0
+      }
+    },
+    /**
+     * FETCH SINGLE FOLDER BY PATH
+     */
+    async folderByPath (obj, args, context) {
+      const parentPathParts = args.path.replaceAll('/', '.').replaceAll('-', '_').split('.')
+      const folder = await WIKI.db.knex('tree')
+        .select(WIKI.db.knex.raw('tree.*, nlevel(tree."folderPath") AS depth'))
+        .where({
+          siteId: args.siteId,
+          localeCode: args.locale,
+          folderPath: _.dropRight(parentPathParts).join('.'),
+          fileName: _.last(parentPathParts)
+        })
+        .first()
+
+      if (!folder) {
+        throw new Error('ERR_FOLDER_NOT_EXIST')
+      }
+
+      return {
+        ...folder,
+        folderPath: folder.folderPath.replaceAll('.', '/').replaceAll('_', '-'),
+        childrenCount: folder.meta?.children || 0
       }
     }
   },
@@ -108,48 +153,8 @@ module.exports = {
      */
     async createFolder (obj, args, context) {
       try {
-        WIKI.logger.debug(`Creating new folder ${args.pathName}...`)
-
-        // Get parent path
-        let parentPath = ''
-        if (args.parentId) {
-          const parent = await WIKI.db.knex('tree').where('id', args.parentId).first()
-          parentPath = parent ? `${parent.folderPath}.${parent.fileName}` : ''
-          if (parent) {
-            parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
-          }
-          parentPath = parentPath.replaceAll('-', '_')
-        }
-
-        // Validate path name
-        if (!rePathName.test(args.pathName)) {
-          throw new Error('ERR_INVALID_PATH_NAME')
-        }
+        await WIKI.db.tree.createFolder(args)
 
-        // Validate title
-        if (!reTitle.test(args.title)) {
-          throw new Error('ERR_INVALID_TITLE')
-        }
-
-        // Check for collision
-        const existingFolder = await WIKI.db.knex('tree').where({
-          siteId: args.siteId,
-          folderPath: parentPath,
-          fileName: args.pathName
-        }).first()
-        if (existingFolder) {
-          throw new Error('ERR_FOLDER_ALREADY_EXISTS')
-        }
-
-        // Create folder
-        WIKI.logger.debug(`Creating new folder ${args.pathName} at path /${parentPath}...`)
-        await WIKI.db.knex('tree').insert({
-          folderPath: parentPath,
-          fileName: args.pathName,
-          type: 'folder',
-          title: args.title,
-          siteId: args.siteId
-        })
         return {
           operation: graphHelper.generateSuccess('Folder created successfully')
         }
@@ -163,59 +168,7 @@ module.exports = {
      */
     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.`)
+        await WIKI.db.tree.renameFolder(args)
 
         return {
           operation: graphHelper.generateSuccess('Folder renamed successfully')
@@ -230,39 +183,7 @@ module.exports = {
      */
     async deleteFolder (obj, args, context) {
       try {
-        // Get folder
-        const folder = await WIKI.db.knex('tree').where('id', args.folderId).first()
-        const folderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
-        WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`)
-
-        // Delete all children
-        const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '<@', folderPath).del().returning(['id', 'type'])
-
-        // Delete folders
-        const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id)
-        if (deletedFolders.length > 0) {
-          WIKI.logger.debug(`Deleted ${deletedFolders.length} children folders.`)
-        }
-
-        // Delete pages
-        const deletedPages = deletedNodes.filter(n => n.type === 'page').map(n => n.id)
-        if (deletedPages.length > 0) {
-          WIKI.logger.debug(`Deleting ${deletedPages.length} children pages...`)
-
-          // TODO: Delete page
-        }
-
-        // Delete assets
-        const deletedAssets = deletedNodes.filter(n => n.type === 'asset').map(n => n.id)
-        if (deletedAssets.length > 0) {
-          WIKI.logger.debug(`Deleting ${deletedPages.length} children assets...`)
-
-          // TODO: Delete asset
-        }
-
-        // Delete the folder itself
-        await WIKI.db.knex('tree').where('id', folder.id).del()
-        WIKI.logger.debug(`Deleted folder ${folder.id} successfully.`)
+        await WIKI.db.tree.deleteFolder(args.folderId)
 
         return {
           operation: graphHelper.generateSuccess('Folder deleted successfully')

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

@@ -14,16 +14,24 @@ extend type Query {
     orderByDirection: OrderByDirection
     depth: Int
     includeAncestors: Boolean
+    includeRootItems: Boolean
     ): [TreeItem]
   folderById(
     id: UUID!
   ): TreeItemFolder
+  folderByPath(
+    siteId: UUID!
+    locale: String!
+    path: String!
+  ): TreeItemFolder
 }
 
 extend type Mutation {
   createFolder(
     siteId: UUID!
+    locale: String!
     parentId: UUID
+    parentPath: String
     pathName: String!
     title: String!
   ): DefaultResponse

+ 307 - 0
server/models/tree.js

@@ -0,0 +1,307 @@
+const Model = require('objection').Model
+const _ = require('lodash')
+
+const rePathName = /^[a-z0-9-]+$/
+const reTitle = /^[^<>"]+$/
+
+/**
+ * Tree model
+ */
+module.exports = class Tree extends Model {
+  static get tableName() { return 'tree' }
+
+  static get jsonSchema () {
+    return {
+      type: 'object',
+      required: ['fileName'],
+
+      properties: {
+        id: {type: 'string'},
+        folderPath: {type: 'string'},
+        fileName: {type: 'string'},
+        type: {type: 'string'},
+        title: {type: 'string'},
+        createdAt: {type: 'string'},
+        updatedAt: {type: 'string'}
+      }
+    }
+  }
+
+  static get jsonAttributes() {
+    return ['meta']
+  }
+
+  static get relationMappings() {
+    return {
+      locale: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: require('./locales'),
+        join: {
+          from: 'tree.localeCode',
+          to: 'locales.code'
+        }
+      },
+      site: {
+        relation: Model.BelongsToOneRelation,
+        modelClass: require('./sites'),
+        join: {
+          from: 'tree.siteId',
+          to: 'sites.id'
+        }
+      }
+    }
+  }
+
+  $beforeUpdate() {
+    this.updatedAt = new Date().toISOString()
+  }
+  $beforeInsert() {
+    this.createdAt = new Date().toISOString()
+    this.updatedAt = new Date().toISOString()
+  }
+
+  /**
+   * Create New Folder
+   *
+   * @param {Object} args - New Folder Properties
+   * @param {string} [args.parentId] - UUID of the parent folder
+   * @param {string} [args.parentPath] - Path of the parent folder
+   * @param {string} args.pathName - Path name of the folder to create
+   * @param {string} args.title - Title of the folder to create
+   * @param {string} args.locale - Locale code of the folder to create
+   * @param {string} args.siteId - UUID of the site in which the folder will be created
+   */
+  static async createFolder ({ parentId, parentPath, pathName, title, locale, siteId }) {
+    // Validate path name
+    if (!rePathName.test(pathName)) {
+      throw new Error('ERR_INVALID_PATH_NAME')
+    }
+
+    // Validate title
+    if (!reTitle.test(title)) {
+      throw new Error('ERR_INVALID_TITLE')
+    }
+
+    WIKI.logger.debug(`Creating new folder ${pathName}...`)
+    parentPath = parentPath?.replaceAll('/', '.')?.replaceAll('-', '_') || ''
+    const parentPathParts = parentPath.split('.')
+    const parentFilter = {
+      folderPath: _.dropRight(parentPathParts).join('.'),
+      fileName: _.last(parentPathParts)
+    }
+
+    // Get parent path
+    let parent = null
+    if (parentId) {
+      parent = await WIKI.db.knex('tree').where('id', parentId).first()
+      if (!parent) {
+        throw new Error('ERR_NONEXISTING_PARENT_ID')
+      }
+      parentPath = parent.folderPath ? `${parent.folderPath}.${parent.fileName}` : parent.fileName
+    } else if (parentPath) {
+      parent = await WIKI.db.knex('tree').where(parentFilter).first()
+    } else {
+      parentPath = ''
+    }
+
+    // Check for collision
+    const existingFolder = await WIKI.db.knex('tree').where({
+      siteId: siteId,
+      localeCode: locale,
+      folderPath: parentPath,
+      fileName: pathName
+    }).first()
+    if (existingFolder) {
+      throw new Error('ERR_FOLDER_ALREADY_EXISTS')
+    }
+
+    // Ensure all ancestors exist
+    if (parentPath) {
+      const expectedAncestors = []
+      const existingAncestors = await WIKI.db.knex('tree').select('folderPath', 'fileName').where(builder => {
+        const parentPathParts = parentPath.split('.')
+        for (let i = 1; i <= parentPathParts.length; i++) {
+          const ancestor = {
+            folderPath: _.dropRight(parentPathParts, i).join('.'),
+            fileName: _.nth(parentPathParts, i * -1)
+          }
+          expectedAncestors.push(ancestor)
+          builder.orWhere({
+            ...ancestor,
+            type: 'folder'
+          })
+        }
+      })
+      for (const ancestor of _.differenceWith(expectedAncestors, existingAncestors, (expAnc, exsAnc) => expAnc.folderPath === exsAnc.folderPath && expAnc.fileName === exsAnc.fileName)) {
+        WIKI.logger.debug(`Creating missing parent folder ${ancestor.fileName} at path /${ancestor.folderPath}...`)
+        const newAncestor = await WIKI.db.knex('tree').insert({
+          ...ancestor,
+          type: 'folder',
+          title: ancestor.fileName,
+          localeCode: locale,
+          siteId: siteId,
+          meta: {
+            children: 1
+          }
+        }).returning('*')
+
+        // Parent didn't exist until now, assign it
+        if (!parent && ancestor.folderPath === parentFilter.folderPath && ancestor.fileName === parentFilter.fileName) {
+          parent = newAncestor
+        }
+      }
+    }
+
+    // Create folder
+    WIKI.logger.debug(`Creating new folder ${pathName} at path /${parentPath}...`)
+    await WIKI.db.knex('tree').insert({
+      folderPath: parentPath,
+      fileName: pathName,
+      type: 'folder',
+      title: title,
+      localeCode: locale,
+      siteId: siteId,
+      meta: {
+        children: 0
+      }
+    })
+
+    // Update parent ancestor count
+    if (parent) {
+      await WIKI.db.knex('tree').where('id', parent.id).update({
+        meta: {
+          ...(parent.meta ?? {}),
+          children: (parent.meta?.children || 0) + 1
+        }
+      })
+    }
+  }
+
+  /**
+   * Rename a folder
+   *
+   * @param {Object} args - Rename Folder Properties
+   * @param {string} args.folderId - UUID of the folder to rename
+   * @param {string} args.pathName - New path name of the folder
+   * @param {string} args.title - New title of the folder
+   */
+  static async renameFolder ({ folderId, pathName, title }) {
+    // Get folder
+    const folder = await WIKI.db.knex('tree').where('id', folderId).first()
+    if (!folder) {
+      throw new Error('ERR_NONEXISTING_FOLDER_ID')
+    }
+
+    // Validate path name
+    if (!rePathName.test(pathName)) {
+      throw new Error('ERR_INVALID_PATH_NAME')
+    }
+
+    // Validate title
+    if (!reTitle.test(title)) {
+      throw new Error('ERR_INVALID_TITLE')
+    }
+
+    WIKI.logger.debug(`Renaming folder ${folder.id} path to ${pathName}...`)
+
+    if (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: 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}.${pathName}` : 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: pathName,
+        title: title
+      })
+    } else {
+      // Update the folder title only
+      await WIKI.db.knex('tree').where('id', folder.id).update({
+        title: title
+      })
+    }
+
+    WIKI.logger.debug(`Renamed folder ${folder.id} successfully.`)
+  }
+
+  /**
+   * Delete a folder
+   *
+   * @param {String} folderId Folder ID
+   */
+  static async deleteFolder (folderId) {
+    // Get folder
+    const folder = await WIKI.db.knex('tree').where('id', folderId).first()
+    if (!folder) {
+      throw new Error('ERR_NONEXISTING_FOLDER_ID')
+    }
+    const folderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
+    WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`)
+
+    // Delete all children
+    const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '<@', folderPath).del().returning(['id', 'type'])
+
+    // Delete folders
+    const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id)
+    if (deletedFolders.length > 0) {
+      WIKI.logger.debug(`Deleted ${deletedFolders.length} children folders.`)
+    }
+
+    // Delete pages
+    const deletedPages = deletedNodes.filter(n => n.type === 'page').map(n => n.id)
+    if (deletedPages.length > 0) {
+      WIKI.logger.debug(`Deleting ${deletedPages.length} children pages...`)
+
+      // TODO: Delete page
+    }
+
+    // Delete assets
+    const deletedAssets = deletedNodes.filter(n => n.type === 'asset').map(n => n.id)
+    if (deletedAssets.length > 0) {
+      WIKI.logger.debug(`Deleting ${deletedPages.length} children assets...`)
+
+      // TODO: Delete asset
+    }
+
+    // Delete the folder itself
+    await WIKI.db.knex('tree').where('id', folder.id).del()
+
+    // Update parent children count
+    if (folder.folderPath) {
+      const parentPathParts = folder.folderPath.split('.')
+      const parent = await WIKI.db.knex('tree').where({
+        folderPath: _.dropRight(parentPathParts).join('.'),
+        fileName: _.last(parentPathParts)
+      }).first()
+      await WIKI.db.knex('tree').where('id', parent.id).update({
+        meta: {
+          ...(parent.meta ?? {}),
+          children: (parent.meta?.children || 1) - 1
+        }
+      })
+    }
+
+    WIKI.logger.debug(`Deleted folder ${folder.id} successfully.`)
+  }
+}

文件差異過大導致無法顯示
+ 0 - 0
ux/public/_assets/icons/carbon-copy-empty-box.svg


+ 31 - 0
ux/public/_assets/icons/fluent-bot-animated.svg

@@ -0,0 +1,31 @@
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 48 48" width="96px" height="96px">
+	<defs>
+		<linearGradient id="p0leOTPLvuNkjL_fSa~qVa" x1="24" x2="24" y1="9.109" y2="13.568" data-name="Безымянный градиент 6" gradientUnits="userSpaceOnUse">
+			<stop offset="0" stop-color="#0077d2"/>
+			<stop offset="1" stop-color="#0b59a2"/>
+		</linearGradient>
+		<linearGradient id="p0leOTPLvuNkjL_fSa~qVb" x1="4.5" x2="4.5" y1="26.717" y2="41.786" xlink:href="#p0leOTPLvuNkjL_fSa~qVa"/>
+		<linearGradient id="p0leOTPLvuNkjL_fSa~qVc" x1="43.5" x2="43.5" y1="26.717" y2="41.786" xlink:href="#p0leOTPLvuNkjL_fSa~qVa"/>
+		<linearGradient id="p0leOTPLvuNkjL_fSa~qVd" x1="16" x2="16" y1="25.054" y2="43.495" xlink:href="#p0leOTPLvuNkjL_fSa~qVa"/>
+		<linearGradient id="p0leOTPLvuNkjL_fSa~qVe" x1="32" x2="32" y1="25.054" y2="43.495" xlink:href="#p0leOTPLvuNkjL_fSa~qVa"/>
+	</defs>
+	<rect width="2" height="6" x="23" y="8" fill="url(#p0leOTPLvuNkjL_fSa~qVa)"/>
+	<path fill="url(#p0leOTPLvuNkjL_fSa~qVb)" d="M6,27H8a0,0,0,0,1,0,0V37a0,0,0,0,1,0,0H6a5,5,0,0,1-5-5v0A5,5,0,0,1,6,27Z"/>
+	<path fill="url(#p0leOTPLvuNkjL_fSa~qVc)" d="M40,27h2a5,5,0,0,1,5,5v0a5,5,0,0,1-5,5H40a0,0,0,0,1,0,0V27A0,0,0,0,1,40,27Z"/>
+	<path fill="#199be2" d="M24,13h0A18,18,0,0,1,42,31v8a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2V31A18,18,0,0,1,24,13Z"/>
+	<circle cx="16" cy="31" r="6" fill="url(#p0leOTPLvuNkjL_fSa~qVd)"/>
+	<circle cx="32" cy="31" r="6" fill="url(#p0leOTPLvuNkjL_fSa~qVe)"/>
+	<circle cx="32" cy="31" r="4" fill="#50e6ff"/>
+	<circle cx="32" cy="31" r="2" fill="url(#p0leOTPLvuNkjL_fSa~qVe)"/>
+	<circle cx="16" cy="31" r="4" fill="#50e6ff"/>
+	<circle cx="16" cy="31" r="2" fill="url(#p0leOTPLvuNkjL_fSa~qVd)"/>
+	<circle cx="24" cy="8" r="2" fill="#199be2"/>
+  <circle cx="24" cy="8" r="3" fill="#02C39A">
+    <animate attributeName="opacity" dur="1.5s" values="0;1;0;0" repeatCount="indefinite" begin="0.1" />
+  </circle>
+  <circle cx="24" cy="8" r="3" fill="#f99d4d">
+    <animate attributeName="opacity" dur="1.5s" values="0;0;1;0" repeatCount="indefinite" begin="0.1" />
+  </circle>
+</svg>

文件差異過大導致無法顯示
+ 0 - 0
ux/public/_assets/illustrations/undraw_floating.svg


文件差異過大導致無法顯示
+ 0 - 0
ux/public/_assets/illustrations/undraw_walking_outside.svg


+ 50 - 18
ux/src/components/FileManager.vue

@@ -5,6 +5,12 @@ q-layout.fileman(view='hHh lpR lFr', container)
       q-icon(name='img:/_assets/icons/fluent-folder.svg', left, size='md')
       span {{t(`fileman.title`)}}
     q-toolbar(dark)
+      q-btn.q-mr-sm.acrylic-btn(
+        flat
+        color='white'
+        label='EN'
+        style='height: 40px;'
+        )
       q-input(
         dark
         v-model='state.search'
@@ -200,13 +206,12 @@ q-layout.fileman(view='hHh lpR lFr', container)
             :bar-style='barStyle'
             style='height: 100%;'
             )
-            .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.
+            .fileman-loadinglist(v-if='state.fileListLoading')
+              q-spinner.q-mr-sm(color='primary', size='64px', :thickness='1')
+              span.text-primary Fetching folder contents...
+            .fileman-emptylist(v-else-if='files.length < 1')
+              img(src='/_assets/icons/carbon-copy-empty-box.svg')
+              span This folder is empty.
             q-list.fileman-filelist(
               v-else
               :class='state.isCompact && `is-compact`'
@@ -394,8 +399,9 @@ const files = computed(() => {
   }).map(f => {
     switch (f.type) {
       case 'folder': {
+        console.info(f.children)
         f.icon = fileTypes.folder.icon
-        f.caption = t('fileman.folderChildrenCount', f.children, { count: f.children })
+        f.caption = t('fileman.folderChildrenCount', { count: f.children }, f.children)
         break
       }
       case 'page': {
@@ -475,7 +481,7 @@ const currentFileDetails = computed(() => {
 // WATCHERS
 
 watch(() => state.currentFolderId, async (newValue) => {
-  await loadTree(newValue)
+  await loadTree({ parentId: newValue })
 })
 
 // METHODS
@@ -485,11 +491,11 @@ function close () {
 }
 
 async function treeLazyLoad (nodeId, { done, fail }) {
-  await loadTree(nodeId, ['folder'])
+  await loadTree({ parentId: nodeId, types: ['folder'] })
   done()
 }
 
-async function loadTree (parentId, types) {
+async function loadTree ({ parentId = null, parentPath = null, types, initLoad = false }) {
   if (state.isFetching) { return }
   state.isFetching = true
   if (!parentId) {
@@ -579,7 +585,7 @@ async function loadTree (parentId, types) {
                 type: 'folder',
                 title: item.title,
                 fileName: item.fileName,
-                children: 0
+                children: item.childrenCount || 0
               })
             }
             break
@@ -664,7 +670,7 @@ function newFolder (parentId) {
       parentId
     }
   }).onOk(() => {
-    loadTree(parentId)
+    loadTree({ parentId })
   })
 }
 
@@ -676,7 +682,7 @@ function renameFolder (folderId) {
     }
   }).onOk(() => {
     treeComp.value.resetLoaded()
-    loadTree(folderId)
+    loadTree({ parentId: folderId })
   })
 }
 
@@ -698,13 +704,13 @@ function delFolder (folderId, mustReload = false) {
       state.treeRoots = state.treeRoots.filter(n => n !== folderId)
     }
     if (mustReload) {
-      loadTree(state.currentFolderId, null)
+      loadTree({ parentId: state.currentFolderId })
     }
   })
 }
 
 function reloadFolder (folderId) {
-  loadTree(folderId, null)
+  loadTree({ parentId: folderId })
   treeComp.value.resetLoaded()
 }
 
@@ -899,7 +905,7 @@ function delItem (item) {
 // MOUNTED
 
 onMounted(() => {
-  loadTree()
+  loadTree({})
 })
 
 </script>
@@ -956,17 +962,43 @@ onMounted(() => {
     height: 100%;
   }
 
+  &-loadinglist {
+    padding: 16px;
+    font-style: italic;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+
+    > span {
+      margin-top: 16px;
+    }
+  }
+
   &-emptylist {
     padding: 16px;
     font-style: italic;
+    font-size: 1.5em;
+    font-weight: 300;
     display: flex;
+    flex-direction: column;
+    justify-content: center;
     align-items: center;
 
+    > img {
+      opacity: .25;
+      width: 200px;
+    }
+
     @at-root .body--light & {
       color: $grey-6;
     }
     @at-root .body--dark & {
-      color: $dark-4;
+      color: $grey-7;
+
+      > img {
+        filter: invert(1);
+      }
     }
   }
 

+ 3 - 0
ux/src/components/FolderCreateDialog.vue

@@ -143,12 +143,14 @@ async function create () {
       mutation: gql`
         mutation createFolder (
           $siteId: UUID!
+          $locale: String!
           $parentId: UUID
           $pathName: String!
           $title: String!
           ) {
           createFolder (
             siteId: $siteId
+            locale: $locale
             parentId: $parentId
             pathName: $pathName
             title: $title
@@ -162,6 +164,7 @@ async function create () {
       `,
       variables: {
         siteId: siteStore.id,
+        locale: 'en',
         parentId: props.parentId,
         pathName: state.path,
         title: state.title

+ 45 - 38
ux/src/components/PageSaveDialog.vue → ux/src/components/TreeBrowserDialog.vue

@@ -81,7 +81,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
                     :color='state.displayMode === `path` ? `positive` : `grey`'
                     size='xs'
                     )
-                q-item-section.q-pr-sm Browse Using Paths
+                q-item-section.q-pr-sm {{ t('pageSaveDialog.displayModePath') }}
               q-item(clickable, @click='state.displayMode = `title`')
                 q-item-section(side)
                   q-icon(
@@ -89,7 +89,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
                     :color='state.displayMode === `title` ? `positive` : `grey`'
                     size='xs'
                     )
-                q-item-section.q-pr-sm Browse Using Titles
+                q-item-section.q-pr-sm {{ t('pageSaveDialog.displayModeTitle') }}
       q-space
       q-btn.acrylic-btn(
         icon='las la-times'
@@ -131,18 +131,24 @@ const props = defineProps({
   mode: {
     type: String,
     required: false,
-    default: 'save'
+    default: 'pageSave'
   },
-  pageId: {
+  itemId: {
     type: String,
-    required: true
+    required: false,
+    default: ''
   },
-  pageName: {
+  folderPath: {
     type: String,
     required: false,
     default: ''
   },
-  pagePath: {
+  itemTitle: {
+    type: String,
+    required: false,
+    default: ''
+  },
+  itemFileName: {
     type: String,
     required: false,
     default: ''
@@ -177,33 +183,12 @@ const state = reactive({
   currentFileId: '',
   treeNodes: {},
   treeRoots: [],
-  fileList: [
-    {
-      id: '1',
-      type: 'folder',
-      title: 'Beep Boop'
-    },
-    {
-      id: '2',
-      type: 'folder',
-      title: 'Second Folder'
-    },
-    {
-      id: '3',
-      type: 'page',
-      title: 'Some Page',
-      pageType: 'markdown'
-    }
-  ],
+  fileList: [],
   title: '',
-  path: ''
+  path: '',
+  typesToFetch: []
 })
 
-const displayModes = [
-  { value: 'title', label: t('pageSaveDialog.displayModeTitle') },
-  { value: 'path', label: t('pageSaveDialog.displayModePath') }
-]
-
 const thumbStyle = {
   right: '1px',
   borderRadius: '5px',
@@ -249,23 +234,32 @@ async function save () {
 }
 
 async function treeLazyLoad (nodeId, { done, fail }) {
-  await loadTree(nodeId, ['folder', 'page'])
+  await loadTree({
+    parentId: nodeId,
+    types: ['folder', 'page']
+  })
   done()
 }
 
-async function loadTree (parentId, types) {
+async function loadTree ({ parentId = null, parentPath = null, types, initLoad = false }) {
   try {
     const resp = await APOLLO_CLIENT.query({
       query: gql`
         query loadTree (
           $siteId: UUID!
           $parentId: UUID
+          $parentPath: String
           $types: [TreeItemType]
+          $includeAncestors: Boolean
+          $includeRootItems: Boolean
         ) {
           tree (
             siteId: $siteId
             parentId: $parentId
+            parentPath: $parentPath
             types: $types
+            includeAncestors: $includeAncestors
+            includeRootItems: $includeRootItems
           ) {
             __typename
             ... on TreeItemFolder {
@@ -290,7 +284,10 @@ async function loadTree (parentId, types) {
       variables: {
         siteId: siteStore.id,
         parentId,
-        types
+        parentPath,
+        types,
+        includeAncestors: initLoad,
+        includeRootItems: initLoad
       },
       fetchPolicy: 'network-only'
     })
@@ -344,16 +341,26 @@ function newFolder (parentId) {
       parentId
     }
   }).onOk(() => {
-    loadTree(parentId)
+    loadTree({ parentId })
   })
 }
 
 // MOUNTED
 
 onMounted(() => {
-  loadTree()
-  state.title = props.pageName || ''
-  state.path = props.pagePath || ''
+  switch (props.mode) {
+    case 'pageSave': {
+      state.typesToFetch = ['folder', 'page']
+      break
+    }
+  }
+  loadTree({
+    parentPath: props.folderPath,
+    types: state.typesToFetch,
+    initLoad: true
+  })
+  state.title = props.itemTitle || ''
+  state.path = props.itemFileName || ''
 })
 
 </script>

+ 2 - 2
ux/src/i18n/locales/en.json

@@ -1600,8 +1600,8 @@
   "common.actions.newFolder": "New Folder",
   "common.actions.duplicate": "Duplicate",
   "common.actions.moveTo": "Move To",
-  "pageSaveDialog.displayModeTitle": "Title",
-  "pageSaveDialog.displayModePath": "Path",
+  "pageSaveDialog.displayModeTitle": "Browse Using Titles",
+  "pageSaveDialog.displayModePath": "Browse Using Paths",
   "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}",

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

@@ -2,7 +2,7 @@
 q-page.admin-terminal
   .row.q-pa-md.items-center
     .col-auto
-      img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-bot.svg')
+      img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-bot-animated.svg')
     .col.q-pl-md
       .text-h5.text-primary.animated.fadeInLeft {{ t('admin.scheduler.title') }}
       .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.scheduler.subtitle') }}

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

@@ -466,12 +466,13 @@ function togglePageData () {
 
 function duplicatePage () {
   $q.dialog({
-    component: defineAsyncComponent(() => import('../components/PageSaveDialog.vue')),
+    component: defineAsyncComponent(() => import('../components/TreeBrowserDialog.vue')),
     componentProps: {
-      mode: 'duplicate',
-      pageId: pageStore.id,
-      pageName: pageStore.title,
-      pagePath: pageStore.path
+      mode: 'duplicatePage',
+      folderPath: '',
+      itemId: pageStore.id,
+      itemTitle: pageStore.title,
+      itemFileName: pageStore.path
     }
   }).onOk(() => {
     // TODO: change route to new location
@@ -480,12 +481,13 @@ function duplicatePage () {
 
 function renamePage () {
   $q.dialog({
-    component: defineAsyncComponent(() => import('../components/PageSaveDialog.vue')),
+    component: defineAsyncComponent(() => import('../components/TreeBrowserDialog.vue')),
     componentProps: {
-      mode: 'rename',
-      pageId: pageStore.id,
-      pageName: pageStore.title,
-      pagePath: pageStore.path
+      mode: 'renamePage',
+      folderPath: '',
+      itemId: pageStore.id,
+      itemTitle: pageStore.title,
+      itemFileName: pageStore.path
     }
   }).onOk(() => {
     // TODO: change route to new location

+ 4 - 1
ux/src/stores/page.js

@@ -1,6 +1,6 @@
 import { defineStore } from 'pinia'
 import gql from 'graphql-tag'
-import { cloneDeep, last, pick, transform } from 'lodash-es'
+import { cloneDeep, initial, last, pick, transform } from 'lodash-es'
 import { DateTime } from 'luxon'
 
 import { useSiteStore } from './site'
@@ -131,6 +131,9 @@ export const usePageStore = defineStore('page', {
           path: (last(result)?.path || pathPrefix) + `/${value}`
         })
       }, [])
+    },
+    folderPath: (state) => {
+      return initial(state.path.split('/')).join('/')
     }
   },
   actions: {

部分文件因文件數量過多而無法顯示